ble 0.0.1

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: dd8602914859f96e3568d364d41c7c144691353c
4
+ data.tar.gz: db9efd8183e595469309bfb67e9682d9ac5318ff
5
+ SHA512:
6
+ metadata.gz: be6b5f9ca0d679d208f875a1b6a0f8142e68e90873e0825953cf912559b06db1bb19e9e77813ebc271358c6e8b47cb65ede0913fb620318c99a6efd78f885c86
7
+ data.tar.gz: d6aed04d4bc9fc9fdf0735788236675776b3ecfb7f74ab7df70979abccf6b81e9503bee76f3be065986942c1d5139a604a3ff902fc5a82f32eb300fa3771e1a1
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
4
+
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 sdalu
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 all
13
+ 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 THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,27 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.unshift File.expand_path("../lib", __FILE__)
3
+ require "ble/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "ble"
7
+ s.version = BLE::VERSION
8
+ s.authors = [ "Stephane D'Alu" ]
9
+ s.email = [ "stephane.dalu@gmail.com" ]
10
+ s.homepage = "http://github.com/sdalu/ruby-ble"
11
+ s.summary = "Bluetooth Low Energy API"
12
+ s.description = "Allow access to BLE device from ruby"
13
+
14
+ s.add_dependency "ruby-dbus"
15
+
16
+ s.add_development_dependency "yard"
17
+ s.add_development_dependency "rake"
18
+
19
+ s.has_rdoc = 'yard'
20
+
21
+ s.license = 'MIT'
22
+
23
+
24
+ s.files = %w[ LICENSE Gemfile ble.gemspec ] +
25
+ Dir['lib/**/*.rb']
26
+ #s.require_path = 'lib'
27
+ end
@@ -0,0 +1,954 @@
1
+ # coding: utf-8
2
+ require 'dbus'
3
+ require 'logger'
4
+
5
+ # https://github.com/mvidner/ruby-dbus/blob/master/doc/Tutorial.md
6
+ # https://kernel.googlesource.com/pub/scm/bluetooth/bluez/+/refs/heads/master/doc/
7
+
8
+
9
+ #
10
+ module BLE
11
+
12
+
13
+ private
14
+ I_ADAPTER = 'org.bluez.Adapter1'
15
+ I_DEVICE = 'org.bluez.Device1'
16
+ I_AGENT_MANAGER = 'org.bluez.AgentManager1'
17
+ I_AGENT = 'org.bluez.Agent1'
18
+ I_GATT_CHARACTERISTIC = 'org.bluez.GattCharacteristic1'
19
+ I_GATT_SERVICE = 'org.bluez.GattService1'
20
+ I_PROXIMITY_REPORTER = 'org.bluez.ProximityReporter1'
21
+ I_PROPERTIES = 'org.freedesktop.DBus.Properties'
22
+ I_INTROSPECTABLE = 'org.freedesktop.DBus.Introspectable'
23
+
24
+ E_IN_PROGRESS = 'org.bluez.Error.InProgress'
25
+ E_FAILED = 'org.bluez.Error.Failed'
26
+ E_NOT_READY = 'org.bluez.Error.NotReady'
27
+ E_ALREADY_CONNECTED = 'org.bluez.Error.AlreadyConnected'
28
+ E_NOT_CONNECTED = 'org.bluez.Error.NotConnected'
29
+ E_DOES_NOT_EXIST = 'org.bluez.Error.DoesNotExist'
30
+ E_NOT_SUPPORTED = 'org.bluez.Error.NotSupported'
31
+ E_NOT_AUTHORIZED = 'org.bluez.Error.NotAuthorized'
32
+ E_INVALID_ARGUMENTS = 'org.bluez.Error.InvalidArguments'
33
+ E_ALREADY_EXISTS = 'org.bluez.Error.AlreadyExists'
34
+ E_AUTH_CANCELED = 'org.bluez.Error.AuthenticationCanceled'
35
+ E_AUTH_FAILED = 'org.bluez.Error.AuthenticationFailed'
36
+ E_AUTH_REJECTED = 'org.bluez.Error.AuthenticationRejected'
37
+ E_AUTH_TIMEOUT = 'org.bluez.Error.AuthenticationTimeout'
38
+ E_AUTH_ATTEMPT_FAILED = 'org.bluez.Error.ConnectionAttemptFailed'
39
+
40
+ E_UNKNOWN_OBJECT = 'org.freedesktop.DBus.Error.UnknownObject'
41
+ E_INVALID_ARGS = 'org.freedesktop.DBus.Error.InvalidArgs'
42
+ E_INVALID_SIGNATURE = 'org.freedesktop.DBus.Error.InvalidSignature'
43
+
44
+ DBUS = DBus.system_bus
45
+ BLUEZ = DBUS.service('org.bluez')
46
+
47
+ public
48
+ class Error < StandardError ; end
49
+ class NotYetImplemented < Error ; end
50
+ class StalledObject < Error ; end
51
+ class NotReady < Error ; end
52
+ class NotAuthorized < Error ; end
53
+ class NotConnected < Error ; end
54
+ class NotFound < Error ; end
55
+ class ServiceNotFound < NotFound ; end
56
+ class CharacteristicNotFound < NotFound ; end
57
+ class AccessUnavailable < Error ; end
58
+
59
+
60
+ GATT_BASE_UUID="00000000-0000-1000-8000-00805F9B34FB"
61
+
62
+ #"DisplayOnly", "DisplayYesNo", "KeyboardOnly",
63
+ # "NoInputNoOutput" and "KeyboardDisplay" which
64
+
65
+
66
+ def self.registerAgent(agent, service, path)
67
+ raise NotYetImplemented
68
+ bus = DBus.session_bus
69
+ service = bus.request_service("org.ruby.service")
70
+
71
+ service.export(BLE::Agent.new(agent_path))
72
+
73
+ o_bluez = BLUEZ.object('/org/bluez')
74
+ o_bluez.introspect
75
+ o_bluez[I_AGENT_MANAGER].RegisterAgent(agent_path, "NoInputNoOutput")
76
+ end
77
+
78
+
79
+ class Agent < DBus::Object
80
+ @log = Logger.new($stdout)
81
+ # https://kernel.googlesource.com/pub/scm/bluetooth/bluez/+/refs/heads/master/doc/agent-api.txt
82
+ dbus_interface I_AGENT do
83
+ dbus_method :Release do
84
+ @log.debug "Release()"
85
+ exit false
86
+ end
87
+
88
+ dbus_method :RequestPinCode, "in device:o, out ret:s" do |device|
89
+ @log.debug{ "RequestPinCode(#{device})" }
90
+ ["0000"]
91
+ end
92
+
93
+ dbus_method :RequestPasskey, "in device:o, out ret:u" do |device|
94
+ @log.debug{ "RequestPasskey(#{device})" }
95
+ raise DBus.error("org.bluez.Error.Rejected")
96
+ end
97
+
98
+ dbus_method :DisplayPasskey, "in device:o, in passkey:u, in entered:y" do |device, passkey, entered|
99
+ @log.debug{ "DisplayPasskey(#{device}, #{passkey}, #{entered})" }
100
+ raise DBus.error("org.bluez.Error.Rejected")
101
+ end
102
+
103
+ dbus_method :RequestConfirmation, "in device:o, in passkey:u" do |device, passkey|
104
+ @log.debug{ "RequestConfirmation(#{device}, #{passkey})" }
105
+ raise DBus.error("org.bluez.Error.Rejected")
106
+ end
107
+
108
+ dbus_method :Authorize, "in device:o, in uuid:s" do |device, uuid|
109
+ @log.debug{ "Authorize(#{device}, #{uuid})" }
110
+ end
111
+
112
+ dbus_method :ConfirmModeChange, "in mode:s" do |mode|
113
+ @log.debug{ "ConfirmModeChange(#{mode})" }
114
+ raise DBus.error("org.bluez.Error.Rejected")
115
+ end
116
+
117
+ dbus_method :Cancel do
118
+ @log.debug "Cancel()"
119
+ raise DBus.error("org.bluez.Error.Rejected")
120
+ end
121
+ end
122
+ end
123
+
124
+
125
+ # Adapter class
126
+ # Adapter.list
127
+ # a = Adapter.new('hci0')
128
+ # a.start_discover ; sleep(10) ; a.stop_discovery
129
+ # a.devices
130
+ #
131
+ class Adapter
132
+ # Return a list of available unix device name for the
133
+ # adapter installed on the system.
134
+ # @return [Array<String>] list of unix device name
135
+ def self.list
136
+ o_bluez = BLUEZ.object('/org/bluez')
137
+ o_bluez.introspect
138
+ o_bluez.subnodes.reject {|adapter| ['test'].include?(adapter) }
139
+ end
140
+
141
+ # Create a new Adapter
142
+ #
143
+ # @param iface [String] name of the Unix device
144
+ def initialize(iface)
145
+ @iface = iface.dup.freeze
146
+ @o_adapter = BLUEZ.object("/org/bluez/#{@iface}")
147
+ @o_adapter.introspect
148
+
149
+ @o_adapter[I_PROPERTIES]
150
+ .on_signal('PropertiesChanged') do |intf, props|
151
+ puts "#{intf}: #{props.inspect}"
152
+ case intf
153
+ when I_ADAPTER
154
+ case props['Discovering']
155
+ when true
156
+ when false
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ # The Bluetooth interface name
163
+ # @return [String] name of the Unix device
164
+ def iface
165
+ @iface
166
+ end
167
+
168
+ # The Bluetooth device address.
169
+ # @return [String] MAC address of the adapter
170
+ def address
171
+ @o_adapter[I_ADAPTER]['Address']
172
+ end
173
+
174
+ # The Bluetooth system name (pretty hostname).
175
+ # @return [String]
176
+ def name
177
+ @o_adapter[I_ADAPTER]['Name']
178
+ end
179
+
180
+ # The Bluetooth friendly name.
181
+ # In case no alias is set, it will return the system provided name.
182
+ # @return [String]
183
+ def alias
184
+ @o_adapter[I_ADAPTER]['Alias']
185
+ end
186
+
187
+ # Set the alias name.
188
+ #
189
+ # When resetting the alias with an empty string, the
190
+ # property will default back to system name
191
+ #
192
+ # @param val [String] new alias name.
193
+ # @return [void]
194
+ def alias=(val)
195
+ @o_adapter[I_ADAPTER]['Alias'] = val.nil? ? '' : val.to_str
196
+ nil
197
+ end
198
+
199
+ # Return the device corresponding to the given address.
200
+ # @note The device object returned has a dependency on the adapter.
201
+ #
202
+ # @param address MAC address of the device
203
+ # @return [Device] a device
204
+ def [](address)
205
+ Device.new(@iface, address)
206
+ end
207
+
208
+ # This method sets the device discovery filter for the caller.
209
+ # When this method is called with nil or an empty list of UUIDs,
210
+ # filter is removed.
211
+ #
212
+ # @param uuids a list of uuid to filter on
213
+ # @param rssi RSSI threshold
214
+ # @param pathloss pathloss threshold
215
+ # @param transport [:auto, :bredr, :le]
216
+ # type of scan to run (default: :le)
217
+ # @note need to sync with the adapter-api.txt
218
+ def filter(uuids, rssi: nil, pathloss: nil, transport: :le)
219
+ unless [:auto, :bredr, :le].include?(transport)
220
+ raise ArgumentError,
221
+ "transport must be one of :auto, :bredr, :le"
222
+ end
223
+ filter = { }
224
+
225
+ unless uuids.nil? || uuids.empty?
226
+ filter['UUIDs' ] = DBus.variant('as', uuids)
227
+ end
228
+ unless rssi.nil?
229
+ filter['RSSI' ] = DBus.variant('n', rssi)
230
+ end
231
+ unless pathloss.nil?
232
+ filter['Pathloss' ] = DBus.variant('q', pathloss)
233
+ end
234
+ unless transport.nil?
235
+ filter['Transport'] = DBus.variant('s', transport.to_s)
236
+ end
237
+
238
+ @o_adapter[I_ADAPTER].SetDiscoveryFilter(filter)
239
+
240
+ self
241
+ end
242
+
243
+ # Starts the device discovery session.
244
+ # This includes an inquiry procedure and remote device name resolving.
245
+ # Use stop_discovery to release the sessions acquired.
246
+ # This process will start creating device objects as new devices
247
+ # are discovered.
248
+ #
249
+ # @return [Boolean]
250
+ def start_discovery
251
+ @o_adapter[I_ADAPTER].StartDiscovery
252
+ true
253
+ rescue DBus::Error => e
254
+ case e.name
255
+ when E_IN_PROGRESS then true
256
+ when E_FAILED then false
257
+ else raise ScriptError
258
+ end
259
+ end
260
+
261
+ # This method will cancel any previous #start_discovery
262
+ # transaction.
263
+ # @note The discovery procedure is shared
264
+ # between all discovery sessions thus calling stop_discovery
265
+ # will only release a single session.
266
+ #
267
+ # @return [Boolean]
268
+ def stop_discovery
269
+ @o_adapter[I_ADAPTER].StopDiscovery
270
+ true
271
+ rescue DBus::Error => e
272
+ case e.name
273
+ when E_FAILED then false
274
+ when E_NOT_READY then false
275
+ when E_NOT_AUTHORIZED then raise NotAuthorized
276
+ else raise ScriptError
277
+ end
278
+
279
+ end
280
+
281
+ # List of devices MAC address that have been discovered.
282
+ #
283
+ # @return [Array<String>] List of devices MAC address.
284
+ def devices
285
+ @o_adapter.introspect # Force refresh
286
+ @o_adapter.subnodes.map {|dev| # Format: dev_aa_bb_cc_dd_ee_ff
287
+ dev[4..-1].tr('_', ':') }
288
+ end
289
+ end
290
+
291
+ # Create de Device object
292
+ # d = Device::new('hci0', 'aa:bb:dd:dd:ee:ff')
293
+ # d = Adapter.new('hci0')['aa:bb:dd:dd:ee:ff']
294
+ #
295
+ # d.services
296
+ # d.characteristics(:environmental_sensing)
297
+ # d[:environmental_sensing, :temperature]
298
+ #
299
+ class Device
300
+ # @param adapter
301
+ # @param dev
302
+ # @param auto_refresh
303
+ def initialize(adapter, dev, auto_refresh: true)
304
+ @adapter, @dev = adapter, dev
305
+ @auto_refresh = auto_refresh
306
+ @services = {}
307
+
308
+ @n_adapter = adapter
309
+ @p_adapter = "/org/bluez/#{@n_adapter}"
310
+ @o_adapter = BLUEZ.object(@p_adapter)
311
+ @o_adapter.introspect
312
+
313
+ @n_dev = 'dev_' + dev.tr(':', '_')
314
+ @p_dev = "/org/bluez/#{@n_adapter}/#{@n_dev}"
315
+ @o_dev = BLUEZ.object(@p_dev)
316
+ @o_dev.introspect
317
+
318
+ self.refresh if @auto_refresh
319
+
320
+ @o_dev[I_PROPERTIES]
321
+ .on_signal('PropertiesChanged') do |intf, props|
322
+ puts "#{intf}: #{props.inspect}"
323
+ case intf
324
+ when I_DEVICE
325
+ case props['Connected']
326
+ when true
327
+ self.refresh if @auto_refresh
328
+ end
329
+ end
330
+ end
331
+
332
+
333
+
334
+ end
335
+
336
+ # This removes the remote device object.
337
+ # It will remove also the pairing information.
338
+ # @return [Boolean]
339
+ def remove
340
+ @o_adapter[I_ADAPTER].RemoveDevice(@p_dev)
341
+ true
342
+ rescue DBus::Error => e
343
+ case e.name
344
+ when E_FAILED then false
345
+ when E_DOES_NOT_EXIST then raise StalledObject
346
+ when E_UNKNOWN_OBJECT then raise StalledObject
347
+ else raise ScriptError
348
+ end
349
+ end
350
+
351
+
352
+ # This method will connect to the remote device,
353
+ # initiate pairing and then retrieve all SDP records
354
+ # (or GATT primary services).
355
+ # If the application has registered its own agent,
356
+ # then that specific agent will be used. Otherwise
357
+ # it will use the default agent.
358
+ # Only for applications like a pairing wizard it
359
+ # would make sense to have its own agent. In almost
360
+ # all other cases the default agent will handle this just fine.
361
+ # In case there is no application agent and also
362
+ # no default agent present, this method will fail.
363
+ # @return [Boolean]
364
+ def pair
365
+ @o_dev[I_DEVICE].Pair
366
+ true
367
+ rescue DBus::Error => e
368
+ case e.name
369
+ when E_INVALID_ARGUMENTS then false
370
+ when E_FAILED then false
371
+ when E_ALREADY_EXISTS then true
372
+ when E_AUTH_CANCELED then raise NotAutorized
373
+ when E_AUTH_FAILED then raise NotAutorized
374
+ when E_AUTH_REJECTED then raise NotAutorized
375
+ when E_AUTH_TIMEOUT then raise NotAutorized
376
+ when E_AUTH_ATTEMPT_FAILED then raise NotAutorized
377
+ else raise ScriptError
378
+ end
379
+ end
380
+
381
+ # This method can be used to cancel a pairing
382
+ # operation initiated by the Pair method.
383
+ # @return [Boolean]
384
+ def cancel_pairing
385
+ @o_dev[I_DEVICE].CancelPairing
386
+ true
387
+ rescue DBus::Error => e
388
+ case e.name
389
+ when E_DOES_NOT_EXIST then true
390
+ when E_FAILED then false
391
+ else raise ScriptError
392
+ end
393
+ end
394
+
395
+ # This connect to the specified profile UUID or to any (:all)
396
+ # profiles the remote device supports that can be connected to
397
+ # and have been flagged as auto-connectable on our side. If
398
+ # only subset of profiles is already connected it will try to
399
+ # connect currently disconnected ones. If at least one
400
+ # profile was connected successfully this method will indicate
401
+ # success.
402
+ # @return [Boolean]
403
+ def connect(profile=:all)
404
+ case profile
405
+ when UUID::REGEX
406
+ @o_dev[I_DEVICE].ConnectProfile(profile)
407
+ when :all
408
+ @o_dev[I_DEVICE].Connect()
409
+ else raise ArgumentError, "profile uuid or :all expected"
410
+ end
411
+ true
412
+ rescue DBus::Error => e
413
+ case e.name
414
+ when E_NOT_READY
415
+ when E_FAILED
416
+ when E_IN_PROGRESS
417
+ false
418
+ when E_ALREADY_CONNECTED
419
+ true
420
+ when E_UNKNOWN_OBJECT
421
+ raise StalledObject
422
+ else raise ScriptError
423
+ end
424
+ end
425
+
426
+ # This method gracefully disconnects :all connected profiles
427
+ # and then terminates low-level ACL connection.
428
+ # ACL connection will be terminated even if some profiles
429
+ # were not disconnected properly e.g. due to misbehaving device.
430
+ # This method can be also used to cancel a preceding #connect
431
+ # call before a reply to it has been received.
432
+ # If a profile UUID is specified, only this profile is disconnected,
433
+ # and as their is no connection tracking for a profile, so
434
+ # as long as the profile is registered this will always succeed
435
+ # @return [Boolean]
436
+ def disconnect(profile=:all)
437
+ case profile
438
+ when UUID::REGEX
439
+ @o_dev[I_DEVICE].DisconnectProfile(profile)
440
+ when :all
441
+ @o_dev[I_DEVICE].Disconnect()
442
+ else raise ArgumentError, "profile uuid or :all expected"
443
+ end
444
+ true
445
+ rescue DBus::Error => e
446
+ case e.name
447
+ when E_FAILED
448
+ when E_IN_PROGRESS
449
+ false
450
+ when E_INVALID_ARGUMENTS
451
+ raise ArgumentError, "unsupported profile (#{profile})"
452
+ when E_NOT_SUPPORTED
453
+ raise NotSuppported
454
+ when E_NOT_CONNECTED
455
+ true
456
+ when E_UNKNOWN_OBJECT
457
+ raise StalledObject
458
+ else raise ScriptError
459
+ end
460
+ end
461
+
462
+ # Indicates if the remote device is paired
463
+ def is_paired?
464
+ @o_dev[I_DEVICE]['Paired']
465
+ rescue DBus::Error => e
466
+ case e.name
467
+ when E_UNKNOWN_OBJECT
468
+ raise StalledObject
469
+ else raise ScriptError
470
+ end
471
+ end
472
+
473
+ # Indicates if the remote device is currently connected.
474
+ def is_connected?
475
+ @o_dev[I_DEVICE]['Connected']
476
+ rescue DBus::Error => e
477
+ case e.name
478
+ when E_UNKNOWN_OBJECT
479
+ raise StalledObject
480
+ else raise
481
+ end
482
+ end
483
+
484
+ # List of available services as UUID
485
+ #
486
+ # @raise [NotConnected] if device is not in a connected state
487
+ # @note The list is retrieve once when object is
488
+ # connected if auto_refresh is enable, otherwise
489
+ # you need to call #refresh
490
+ # @note This is the list of UUID for which we have an entry
491
+ # in the bluez-dbus
492
+ # @return [Array<String>] List of service UUID
493
+ def services
494
+ raise NotConnected unless is_connected?
495
+ @services.keys
496
+ end
497
+
498
+ # Check if service is available on the device
499
+ # @return [Boolean]
500
+ def has_service?(service)
501
+ @service.key?(_uuid_service(service))
502
+ end
503
+
504
+ # List of available characteristics UUID for a service
505
+ #
506
+ # @param service service can be a UUID, a service type or
507
+ # a service nickname
508
+ # @return [Array<String>, nil] list of characteristics or nil if the
509
+ # service doesn't exist
510
+ # @raise [NotConnected] if device is not in a connected state
511
+ # @note The list is retrieve once when object is
512
+ # connected if auto_refresh is enable, otherwise
513
+ # you need to call #refresh
514
+ def characteristics(service)
515
+ raise NotConnected unless is_connected?
516
+ if chars = _characteristics(service)
517
+ chars.keys
518
+ end
519
+ end
520
+
521
+ # The Bluetooth device address of the remote device
522
+ # @return [String]
523
+ def address
524
+ @o_dev[I_DEVICE]['Address']
525
+ end
526
+
527
+ # The Bluetooth remote name.
528
+ # It is better to always use the #alias when displaying the
529
+ # devices name.
530
+ # @return [String]
531
+ def name # optional
532
+ @o_dev[I_DEVICE]['Name']
533
+ end
534
+
535
+ # The name alias for the remote device.
536
+ # The alias can be used to have a different friendly name for the
537
+ # remote device.
538
+ # In case no alias is set, it will return the remote device name.
539
+ # @return [String]
540
+ def alias
541
+ @o_dev[I_DEVICE]['Alias']
542
+ end
543
+ # Setting an empty string or nil as alias will convert it
544
+ # back to the remote device name.
545
+ # @param val [String, nil]
546
+ # @return [void]
547
+ def alias=(val)
548
+ @o_dev[I_DEVICE]['Alias'] = val.nil? ? "" : val.to_str
549
+ end
550
+
551
+ # Is the device trusted?
552
+ # @return [Boolean]
553
+ def is_trusted?
554
+ @o_dev[I_DEVICE]['Trusted']
555
+ end
556
+
557
+ # Indicates if the remote is seen as trusted. This
558
+ # setting can be changed by the application.
559
+ # @param val [Boolean]
560
+ # @return [void]
561
+ def trusted=(val)
562
+ if ! [ true, false ].include?(val)
563
+ raise ArgumentError, "value must be a boolean"
564
+ end
565
+ @o_dev[I_DEVICE]['Trusted'] = val
566
+ end
567
+
568
+ # Is the device blocked?
569
+ # @return [Boolean]
570
+ def is_blocked?
571
+ @o_dev[I_DEVICE]['Blocked']
572
+ end
573
+
574
+ # if set to true any incoming connections from the
575
+ # device will be immediately rejected. Any device
576
+ # drivers will also be removed and no new ones will
577
+ # be probed as long as the device is blocked
578
+ # @param val [Boolean]
579
+ # @return [void]
580
+ def blocked=(val)
581
+ if ! [ true, false ].include?(val)
582
+ raise ArgumentError, "value must be a boolean"
583
+ end
584
+ @o_dev[I_DEVICE]['Blocked'] = val
585
+ end
586
+
587
+ # Received Signal Strength Indicator of the remote
588
+ # device (inquiry or advertising).
589
+ # @return [Integer]
590
+ def rssi # optional
591
+ @o_dev[I_DEVICE]['RSSI']
592
+ rescue DBus::Error => e
593
+ case e.name
594
+ when E_INVALID_ARGS then raise NotSupported
595
+ else raise ScriptError
596
+ end
597
+ end
598
+
599
+ # Advertised transmitted power level (inquiry or advertising).
600
+ # @return [Integer]
601
+ def tx_power # optional
602
+ @o_dev[I_DEVICE]['TxPower']
603
+ rescue DBus::Error => e
604
+ case e.name
605
+ when E_INVALID_ARGS then raise NotSupported
606
+ else raise ScriptError
607
+ end
608
+ end
609
+
610
+
611
+ # Refresh list of services and characteristics
612
+ # @return [Boolean]
613
+ def refresh
614
+ refresh!
615
+ true
616
+ rescue NotConnected, StalledObject
617
+ false
618
+ end
619
+
620
+ # Refresh list of services and characteristics
621
+ # @raise [NotConnected] if device is not in a connected state
622
+ # @return [self]
623
+ def refresh!
624
+ raise NotConnected unless is_connected?
625
+ max_wait ||= 1.5 # Use ||= due to the retry
626
+ @services = Hash[@o_dev[I_DEVICE]['GattServices'].map {|p_srv|
627
+ o_srv = BLUEZ.object(p_srv)
628
+ o_srv.introspect
629
+ srv = o_srv[I_PROPERTIES].GetAll(I_GATT_SERVICE).first
630
+ char = Hash[srv['Characteristics'].map {|p_char|
631
+ o_char = BLUEZ.object(p_char)
632
+ o_char.introspect
633
+ uuid = o_char[I_GATT_CHARACTERISTIC]['UUID' ].downcase
634
+ flags = o_char[I_GATT_CHARACTERISTIC]['Flags']
635
+ [ uuid, { :uuid => uuid, :flags => flags, :obj => o_char } ]
636
+ }]
637
+ uuid = srv['UUID'].downcase
638
+ [ uuid, { :uuid => uuid,
639
+ :primary => srv['Primary'],
640
+ :characteristics => char } ]
641
+ }]
642
+ self
643
+ rescue DBus::Error => e
644
+ case e.name
645
+ when E_UNKNOWN_OBJECT
646
+ raise StalledObject
647
+ when E_INVALID_ARGS
648
+ # That's probably because all the bluez information
649
+ # haven't been collected yet on dbus for GattServices
650
+ if max_wait > 0
651
+ sleep(0.25) ; max_wait -= 0.25 ; retry
652
+ end
653
+ raise NotReady
654
+
655
+ else raise ScriptError
656
+ end
657
+ end
658
+
659
+ # @param service [String, Symbol]
660
+ # @param characteristic [String, Symbol]
661
+ # @param raw [Boolean]
662
+ # @raise [NotYetImplemented, NotConnected, ServiceNotFound,
663
+ # CharacteristicNotFound, AccessUnavailable ]
664
+ def [](service, characteristic, raw: false)
665
+ raise NotConnected unless is_connected?
666
+ uuid = _uuid_characteristic(characteristic)
667
+ chars = _characteristics(service)
668
+ raise ServiceNotFound, service if chars.nil?
669
+ char = chars[uuid]
670
+ raise CharacteristicNotFound, characteristic if char.nil?
671
+ flags = char[:flags]
672
+ obj = char[:obj]
673
+ info = Characteristic[uuid]
674
+
675
+ if flags.include?('read')
676
+ val = obj[I_GATT_CHARACTERISTIC].ReadValue().first
677
+ val = val.pack('C*')
678
+ val = info[:in].call(val) if !raw && info && info[:in]
679
+ val
680
+ elsif flags.include?('encrypt-read') ||
681
+ flags.include?('encrypt-authenticated-read')
682
+ raise NotYetImplemented
683
+ else
684
+ raise AccessUnavailable
685
+ end
686
+ end
687
+
688
+ # @param service [String, Symbol]
689
+ # @param characteristic [String, Symbol]
690
+ # @param val [Boolean]
691
+ # @raise [NotYetImplemented, NotConnected, ServiceNotFound,
692
+ # CharacteristicNotFound, AccessUnavailable ]
693
+ def []=(service, characteristic, val, raw: false)
694
+ raise NotConnected unless is_connected?
695
+ uuid = _uuid_characteristic(characteristic)
696
+ chars = _characteristics(service)
697
+ raise ServiceNotFound, service if chars.nil?
698
+ char = chars[uuid]
699
+ raise CharacteristicNotFound, characteristic if char.nil?
700
+ flags = char[:flags]
701
+ obj = char[:obj]
702
+ info = Characteristic[uuid]
703
+
704
+ if flags.include?('write') ||
705
+ flags.include?('write-without-response')
706
+ if !raw && info
707
+ if info[:vrfy] && !info[:vrfy].call(vall)
708
+ raise ArgumentError,
709
+ "bad value for characteristic '#{characteristic}'"
710
+ end
711
+ val = info[:out].call(val) if info[:out]
712
+ end
713
+ val = val.unpack('C*')
714
+ obj[I_GATT_CHARACTERISTIC].WriteValue(val)
715
+ elsif flags.include?('encrypt-write') ||
716
+ flags.include?('encrypt-authenticated-write')
717
+ raise NotYetImplemented
718
+ else
719
+ raise AccessUnavailable
720
+ end
721
+ end
722
+
723
+ private
724
+
725
+ def _characteristics(service)
726
+ if srv = @services[_uuid_service(service)]
727
+ srv[:characteristics]
728
+ end
729
+ end
730
+ def _uuid_service(service)
731
+ uuid = case service
732
+ when Symbol
733
+ if i = Service::NICKNAME[service]
734
+ i[:uuid]
735
+ end
736
+ when UUID::REGEX
737
+ service.downcase
738
+ when String
739
+ if i = Service::TYPE[service]
740
+ i[:uuid]
741
+ end
742
+ end
743
+ if uuid.nil?
744
+ raise ArgumentError, "unable to get UUID for service"
745
+ end
746
+
747
+ uuid
748
+ end
749
+ def _uuid_characteristic(characteristic)
750
+ uuid = case characteristic
751
+ when Symbol
752
+ if i = Characteristic::NICKNAME[characteristic]
753
+ i[:uuid]
754
+ end
755
+ when UUID::REGEX
756
+ characteristic.downcase
757
+ when String
758
+ if i = Characteristic::TYPE[characteristic]
759
+ i[:uuid]
760
+ end
761
+ end
762
+ if uuid.nil?
763
+ raise ArgumentError, "unable to get UUID for service"
764
+ end
765
+
766
+ uuid
767
+ end
768
+
769
+
770
+ end
771
+
772
+ def self.UUID(val)
773
+ val.downcase
774
+ end
775
+
776
+ class UUID
777
+ REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
778
+ end
779
+
780
+ class Service
781
+ UUID = {}
782
+ TYPE = {}
783
+ NICKNAME = {}
784
+
785
+ # Get a service description from it's id
786
+ # @param id [Symbol,String]
787
+ # @return [Hash]
788
+ def self.[](id)
789
+ case id
790
+ when Symbol then NICKNAME[id]
791
+ when UUID::REGEX then UUID[id]
792
+ when String then TYPE[id]
793
+ else raise ArgumentError, "invalid type for service id"
794
+ end
795
+ end
796
+
797
+ # Add a service description
798
+ # @param uuid [String]
799
+ # @param name [String]
800
+ # @param type [String]
801
+ def self.add(uuid, name:, type:, **opts)
802
+ if opts.first
803
+ raise ArgumentError, "unknown keyword: #{opts.first[0]}"
804
+ end
805
+
806
+ uuid = case uuid
807
+ when Integer
808
+ if !(0..4294967296).include?(uuid)
809
+ raise ArgumentError, "not a 16bit or 32bit uuid"
810
+ end
811
+ ([uuid].pack("L>").unpack('H*').first +
812
+ "-0000-1000-8000-00805F9B34FB")
813
+
814
+ when String
815
+ if uuid !~ UUID::REGEX
816
+ raise ArgumentError, "not a 128bit uuid string"
817
+ end
818
+ uuid
819
+ else raise ArgumentError, "invalid uuid type"
820
+ end
821
+ uuid = uuid.downcase
822
+ type = type.downcase
823
+
824
+ TYPE[type] = UUID[uuid] = {
825
+ name: name,
826
+ type: type,
827
+ uuid: uuid,
828
+ }
829
+
830
+ stype = type.split('.')
831
+ key = stype.pop.to_sym
832
+ prefix = stype.join('.')
833
+ case prefix
834
+ when 'org.bluetooth.service'
835
+ if NICKNAME.include?(key)
836
+ raise ArgumentError,
837
+ "nickname '#{key}' already registered (type: #{type})"
838
+ end
839
+ NICKNAME[key] = UUID[uuid]
840
+ end
841
+ end
842
+
843
+ def initialize(service)
844
+ @o_srv = BLUEZ.object(service)
845
+ @o_srv.introspect
846
+ end
847
+
848
+
849
+ end
850
+
851
+
852
+
853
+
854
+ class Characteristic
855
+ FLAGS = [ 'broadcast',
856
+ 'read',
857
+ 'write-without-response',
858
+ 'write',
859
+ 'notify',
860
+ 'indicate',
861
+ 'authenticated-signed-writes',
862
+ 'reliable-write',
863
+ 'writable-auxiliaries',
864
+ 'encrypt-read',
865
+ 'encrypt-write',
866
+ 'encrypt-authenticated-read',
867
+ 'encrypt-authenticated-write' ]
868
+
869
+
870
+ UUID = {}
871
+ TYPE = {}
872
+ NICKNAME = {}
873
+
874
+ # Get a characteristic description from it's id
875
+ # @param id [Symbol,String]
876
+ # @return [Hash]
877
+ def self.[](id)
878
+ case id
879
+ when Symbol then NICKNAME[id]
880
+ when UUID::REGEX then UUID[id]
881
+ when String then TYPE[id]
882
+ else raise ArgumentError, "invalid type for characteristic id"
883
+ end
884
+ end
885
+
886
+
887
+ # Add a characteristic description
888
+ # @param uuid [String]
889
+ # @param name [String]
890
+ # @param type [String]
891
+ # @option opts :in [Proc] convert to ruby
892
+ # @option opts :out [Proc] convert to bluetooth data
893
+ # @option opts :vry [Proc] verify
894
+ def self.add(uuid, name:, type:, **opts)
895
+ _in = opts.delete :in
896
+ _out = opts.delete :out
897
+ vrfy = opts.delete :vrfy
898
+ if opts.first
899
+ raise ArgumentError, "unknown keyword: #{opts.first[0]}"
900
+ end
901
+
902
+ uuid = case uuid
903
+ when Integer
904
+ if !(0..4294967296).include?(uuid)
905
+ raise ArgumentError, "not a 16bit or 32bit uuid"
906
+ end
907
+ ([uuid].pack("L>").unpack('H*').first +
908
+ "-0000-1000-8000-00805F9B34FB")
909
+
910
+ when String
911
+ if uuid !~ UUID::REGEX
912
+ raise ArgumentError, "not a 128bit uuid string"
913
+ end
914
+ uuid
915
+ else raise ArgumentError, "invalid uuid type"
916
+ end
917
+ uuid = uuid.downcase
918
+ type = type.downcase
919
+
920
+ TYPE[type] = UUID[uuid] = {
921
+ name: name,
922
+ type: type,
923
+ uuid: uuid,
924
+ in: _in,
925
+ out: _out,
926
+ vrfy: vrfy
927
+ }
928
+
929
+ stype = type.split('.')
930
+ key = stype.pop.to_sym
931
+ prefix = stype.join('.')
932
+ case prefix
933
+ when 'org.bluetooth.characteristic'
934
+ if NICKNAME.include?(key)
935
+ raise ArgumentError,
936
+ "nickname '#{key}' already registered (type: #{type})"
937
+ end
938
+ NICKNAME[key] = UUID[uuid]
939
+ end
940
+ end
941
+ end
942
+
943
+
944
+ # Check if Bluetooth API is accessible
945
+ def self.ok?
946
+ BLUEZ.exists?
947
+ end
948
+
949
+ end
950
+
951
+ require_relative 'ble/db_service'
952
+ require_relative 'ble/db_characteristic'
953
+
954
+
@@ -0,0 +1,71 @@
1
+ module BLE
2
+ class Characteristic
3
+ # C | Integer | 8-bit unsigned (unsigned char)
4
+ # S | Integer | 16-bit unsigned, native endian (uint16_t)
5
+ # L | Integer | 32-bit unsigned, native endian (uint32_t)
6
+ # Q | Integer | 64-bit unsigned, native endian (uint64_t)
7
+ # | |
8
+ # c | Integer | 8-bit signed (signed char)
9
+ # s | Integer | 16-bit signed, native endian (int16_t)
10
+ # l | Integer | 32-bit signed, native endian (int32_t)
11
+ # q | Integer | 64-bit signed, native endian (int64_t)
12
+ #
13
+ # < | Little endian
14
+ # > | Big endian
15
+
16
+
17
+
18
+ add 0x2A6E,
19
+ name: 'Temperature',
20
+ type: 'org.bluetooth.characteristic.temperature',
21
+ vrfy: ->(x) { (0..100).include?(x) },
22
+ in: ->(s) { puts s.inspect ; s.unpack('s<').first.to_f / 100 },
23
+ out: ->(v) { [ v*100 ].pack('s<') }
24
+
25
+ add 0x2A76,
26
+ name: 'UV Index',
27
+ type: 'org.bluetooth.characteristic.uv_index',
28
+ in: ->(s) { s.unpack('C').first },
29
+ out: ->(v) { [ v ].pack('C') }
30
+
31
+ add 0x2A77,
32
+ name: 'Irradiance',
33
+ type: 'org.bluetooth.characteristic.irradiance',
34
+ in: ->(s) { s.unpack('S<').first.to_f / 10 },
35
+ out: ->(v) { [ v*10 ].pack('S<') }
36
+
37
+ add 0x2A7A,
38
+ name: 'Heat Index',
39
+ type: 'org.bluetooth.characteristic.heat_index',
40
+ in: ->(s) { s.unpack('c').first },
41
+ out: ->(v) { [ v ].pack('c') }
42
+
43
+ add 0x2A19,
44
+ name: 'Battery Level',
45
+ type: 'org.bluetooth.characteristic.battery_level',
46
+ vrfy: ->(x) { (0..100).include?(x) },
47
+ in: ->(s) { s.unpack('C').first },
48
+ out: ->(v) { [ v ].pack('C') }
49
+
50
+ add 0x2A6F,
51
+ name: 'Humidity',
52
+ type: 'org.bluetooth.characteristic.humidity',
53
+ vrfy: ->(x) { (0..100).include?(x) },
54
+ in: ->(s) { s.unpack('S<').first.to_f / 100 },
55
+ out: ->(v) { [ v*100 ].pack('S<') }
56
+
57
+ add 0x2A6D,
58
+ name: 'Pressure',
59
+ type: 'org.bluetooth.characteristic.pressure',
60
+ vrfy: ->(x) { x >= 0 },
61
+ in: ->(s) { s.unpack('L<').first.to_f / 10 },
62
+ out: ->(v) { [ v*10 ].pack('L<') }
63
+
64
+ add 0x2AB3,
65
+ name: 'Altitude',
66
+ type: 'org.bluetooth.characteristic.altitude',
67
+ vrfy: ->(x) { x >= 0 },
68
+ in: ->(s) { s.unpack('S<').first },
69
+ out: ->(v) { [ v ].pack('S<') }
70
+ end
71
+ end
@@ -0,0 +1,60 @@
1
+ module BLE
2
+ class Service
3
+ add 0x1800,
4
+ name: 'Generic Access',
5
+ type: 'org.bluetooth.service.generic_access'
6
+
7
+ add 0x1801,
8
+ name: 'Generic Attribute',
9
+ type: 'org.bluetooth.service.generic_attribute'
10
+
11
+ add 0x1802,
12
+ name: 'Immediate Alert',
13
+ type: 'org.bluetooth.service.immediate_alert'
14
+
15
+ add 0x1803,
16
+ name: 'Link Loss',
17
+ type: 'org.bluetooth.service.link_loss'
18
+
19
+ add 0x1804,
20
+ name: 'Tx Power',
21
+ type: 'org.bluetooth.service.tx_power'
22
+
23
+ add 0x1805,
24
+ name: 'Current Time Service',
25
+ type: 'org.bluetooth.service.current_time'
26
+
27
+ add 0x180A,
28
+ name: 'Device Information',
29
+ type: 'org.bluetooth.service.device_information'
30
+
31
+ add 0x180F,
32
+ name: 'Battery Service',
33
+ type: 'org.bluetooth.service.battery_service'
34
+
35
+ add 0x1811,
36
+ name: 'Alert Notification Service',
37
+ type: 'org.bluetooth.service.alert_notification'
38
+
39
+ add 0x1812,
40
+ name: 'Human Interface Device',
41
+ type: 'org.bluetooth.service.human_interface_device'
42
+
43
+ add 0x1819,
44
+ name: 'Location and Navigation',
45
+ type: 'org.bluetooth.service.location_and_navigation'
46
+
47
+ add 0x181A,
48
+ name: 'Environmental Sensing',
49
+ type: 'org.bluetooth.service.environmental_sensing'
50
+
51
+ add 0x181C,
52
+ name: 'User Data',
53
+ type: 'org.bluetooth.service.user_data'
54
+
55
+ add 0x181D,
56
+ name: 'Weight Scale',
57
+ type: 'org.bluetooth.service.weight_scale'
58
+
59
+ end
60
+ end
@@ -0,0 +1,3 @@
1
+ module BLE
2
+ VERSION = "0.0.1"
3
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ble
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Stephane D'Alu
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-12-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ruby-dbus
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: yard
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: Allow access to BLE device from ruby
56
+ email:
57
+ - stephane.dalu@gmail.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - Gemfile
63
+ - LICENSE
64
+ - ble.gemspec
65
+ - lib/ble.rb
66
+ - lib/ble/db_characteristic.rb
67
+ - lib/ble/db_service.rb
68
+ - lib/ble/version.rb
69
+ homepage: http://github.com/sdalu/ruby-ble
70
+ licenses:
71
+ - MIT
72
+ metadata: {}
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubyforge_project:
89
+ rubygems_version: 2.2.2
90
+ signing_key:
91
+ specification_version: 4
92
+ summary: Bluetooth Low Energy API
93
+ test_files: []
94
+ has_rdoc: yard