1#!/usr/bin/env python3

2

3 import argparse

4 import collections

5 import copy

6 import dbus

7 import logging

8 import sys

9

10 import gi

11

12 gi.require_version("GUdev", "1.0")

13 from gi.repository import GObject, GUdev # noqa: E402

14

15 from checkbox_support.dbus import connect_to_system_bus # noqa: E402

16 from checkbox_support.dbus.udisks2 import UDisks2Model # noqa: E402

17 from checkbox_support.dbus.udisks2 import UDisks2Observer # noqa: E402

18 from checkbox_support.dbus.udisks2 import is_udisks2_supported # noqa: E402

19 from checkbox_support.dbus.udisks2 import lookup_udev_device # noqa: E402

20 from checkbox_support.dbus.udisks2 import ( # noqa: E402

21 map_udisks1_connection_bus,

22) # noqa: E402

23 from checkbox_support.heuristics.udisks2 import is_memory_card # noqa: E402

24 from checkbox_support.parsers.udevadm import CARD_READER_RE # noqa: E402

25 from checkbox_support.parsers.udevadm import GENERIC_RE # noqa: E402

26 from checkbox_support.parsers.udevadm import FLASH_RE # noqa: E402

27 from checkbox_support.udev import get_interconnect_speed # noqa: E402

28 from checkbox_support.udev import get_udev_block_devices # noqa: E402

29

30# Record representing properties of a UDisks1 Drive object needed by the

31# UDisks1 version of the watcher implementation

32 UDisks1DriveProperties = collections.namedtuple(

33 "UDisks1DriveProperties", "file bus speed model vendor media"

34)

35

36# Delta record that encapsulates difference:

37# delta_dir -- directon of the difference, either DELTA_DIR_PLUS or

38# DELTA_DIR_MINUS

39# value -- the actual value being removed or added, either InterfaceDelta or

40# PropertyDelta instance, see below

41 DeltaRecord = collections.namedtuple("DeltaRecord", "delta_dir value")

42

43# Delta value for representing interface changes

44 InterfaceDelta = collections.namedtuple(

45 "InterfaceDelta", "delta_type object_path iface_name"

46)

47

48# Delta value for representing property changes

49 PropertyDelta = collections.namedtuple(

50 "PropertyDelta", "delta_type object_path iface_name prop_name prop_value"

51)

52

53# Tokens that encode additions and removals

54 DELTA_DIR_PLUS = "+"

55 DELTA_DIR_MINUS = "-"

56

57# Tokens that encode interface and property deltas

58 DELTA_TYPE_IFACE = "i"

59 DELTA_TYPE_PROP = "p"

60

61

62 def format_bytes(size):

63 """

64 Format size to be easily read by humans

65

66 The result is disk-size compatible (using multiples of 10

67 rather than 2) string like "4.5GB"

68 """

69 for index, prefix in enumerate(" KMGTPEZY", 0):

70 factor = 10 ** (index * 3)

71 if size // factor <= 1000:

72 break

73 return "{}{}B".format(size // factor, prefix.strip())

74

75

76 class UDisks1StorageDeviceListener:

77

78 def __init__(

79 self, system_bus, loop, action, devices, minimum_speed, memorycard

80 ):

81 self._action = action

82 self._devices = devices

83 self._minimum_speed = minimum_speed

84 self._memorycard = memorycard

85 self._bus = system_bus

86 self._loop = loop

87 self._error = False

88 self._change_cache = []

89

90 def check(self, timeout):

91 udisks = "org.freedesktop.UDisks"

92 if self._action == "insert":

93 signal = "DeviceAdded"

94 logging.debug("Adding signal listener for %s.%s", udisks, signal)

95 self._bus.add_signal_receiver(

96 self.add_detected, signal_name=signal, dbus_interface=udisks

97 )

98 elif self._action == "remove":

99 signal = "DeviceRemoved"

100 logging.debug("Adding signal listener for %s.%s", udisks, signal)

101 self._bus.add_signal_receiver(

102 self.remove_detected, signal_name=signal, dbus_interface=udisks

103 )

104

105 self._starting_devices = self.get_existing_devices()

106 logging.debug(

107 "Starting with the following devices: %r", self._starting_devices

108 )

109

110 def timeout_callback():

111 print(

112 "%s seconds have expired "

113 "waiting for the device to be inserted." % timeout

114 )

115 self._error = True

116 self._loop.quit()

117

118 logging.debug("Adding timeout listener, timeout=%r", timeout)

119 GObject.timeout_add_seconds(timeout, timeout_callback)

120 logging.debug("Starting event loop...")

121 self._loop.run()

122

123 return self._error

124

125 def verify_device_change(self, changed_devices, message=""):

126 logging.debug("Verifying device change: %s", changed_devices)

127 # Filter the applicable bus types, as provided on the command line

128 # (values of self._devices can be 'usb', 'firewire', etc)

129 desired_bus_devices = [

130 device for device in changed_devices if device.bus in self._devices

131 ]

132 logging.debug("Desired bus devices: %s", desired_bus_devices)

133 for dev in desired_bus_devices:

134 if self._memorycard:

135 if (

136 dev.bus != "sdio"

137 and not FLASH_RE.search(dev.media)

138 and not CARD_READER_RE.search(dev.model)

139 and not GENERIC_RE.search(dev.vendor)

140 ):

141 logging.debug(

142 "The device does not seem to be a memory"

143 " card (bus: %r, model: %r), skipping",

144 dev.bus,

145 dev.model,

146 )

147 return

148 print(message % {"bus": "memory card", "file": dev.file})

149 else:

150 if (

151 FLASH_RE.search(dev.media)

152 or CARD_READER_RE.search(dev.model)

153 or GENERIC_RE.search(dev.vendor)

154 ):

155 logging.debug(

156 "The device seems to be a memory"

157 " card (bus: %r (model: %r), skipping",

158 dev.bus,

159 dev.model,

160 )

161 return

162 print(message % {"bus": dev.bus, "file": dev.file})

163 if self._minimum_speed:

164 if dev.speed >= self._minimum_speed:

165 print(

166 "with speed of %(speed)s bits/s "

167 "higher than %(min_speed)s bits/s"

168 % {

169 "speed": dev.speed,

170 "min_speed": self._minimum_speed,

171 }

172 )

173 else:

174 print(

175 "ERROR: speed of %(speed)s bits/s lower "

176 "than %(min_speed)s bits/s"

177 % {

178 "speed": dev.speed,

179 "min_speed": self._minimum_speed,

180 }

181 )

182 self._error = True

183 logging.debug("Device matches requirements, exiting event loop")

184 self._loop.quit()

185

186 def job_change_detected(

187 self,

188 devices,

189 job_in_progress,

190 job_id,

191 job_num_tasks,

192 job_cur_task_id,

193 job_cur_task_percentage,

194 ):

195 logging.debug(

196 "UDisks1 reports a job change has been detected:"

197 " devices: %s, job_in_progress: %s, job_id: %s,"

198 " job_num_tasks: %s, job_cur_task_id: %s,"

199 " job_cur_task_percentage: %s",

200 devices,

201 job_in_progress,

202 job_id,

203 job_num_tasks,

204 job_cur_task_id,

205 job_cur_task_percentage,

206 )

207 if job_id == "FilesystemMount":

208 if devices in self._change_cache:

209 logging.debug(

210 "Ignoring filesystem mount,"

211 " the device is present in change cache"

212 )

213 return

214 logging.debug("Adding devices to change cache: %r", devices)

215 self._change_cache.append(devices)

216 logging.debug("Starting devices were: %s", self._starting_devices)

217 current_devices = self.get_existing_devices()

218 logging.debug("Current devices are: %s", current_devices)

219 inserted_devices = list(

220 set(current_devices) - set(self._starting_devices)

221 )

222 logging.debug("Computed inserted devices: %s", inserted_devices)

223 if self._memorycard:

224 message = "Expected memory card device %(file)s inserted"

225 else:

226 message = "Expected %(bus)s device %(file)s inserted"

227 self.verify_device_change(inserted_devices, message=message)

228

229 def add_detected(self, added_path):

230 logging.debug("UDisks1 reports device has been added: %s", added_path)

231 logging.debug("Resetting change_cache to []")

232 self._change_cache = []

233 signal_name = "DeviceJobChanged"

234 dbus_interface = "org.freedesktop.UDisks"

235 logging.debug(

236 "Adding signal listener for %s.%s", dbus_interface, signal_name

237 )

238 self._bus.add_signal_receiver(

239 self.job_change_detected,

240 signal_name=signal_name,

241 dbus_interface=dbus_interface,

242 )

243

244 def remove_detected(self, removed_path):

245 logging.debug(

246 "UDisks1 reports device has been removed: %s", removed_path

247 )

248

249 logging.debug("Starting devices were: %s", self._starting_devices)

250 current_devices = self.get_existing_devices()

251 logging.debug("Current devices are: %s", current_devices)

252 removed_devices = list(

253 set(self._starting_devices) - set(current_devices)

254 )

255 logging.debug("Computed removed devices: %s", removed_devices)

256 self.verify_device_change(

257 removed_devices,

258 message="Removable %(bus)s device %(file)s has been removed",

259 )

260

261 def get_existing_devices(self):

262 logging.debug("Getting existing devices from UDisks1")

263 ud_manager_obj = self._bus.get_object(

264 "org.freedesktop.UDisks", "/org/freedesktop/UDisks"

265 )

266 ud_manager = dbus.Interface(ud_manager_obj, "org.freedesktop.UDisks")

267 existing_devices = []

268 for dev in ud_manager.EnumerateDevices():

269 try:

270 device_obj = self._bus.get_object(

271 "org.freedesktop.UDisks", dev

272 )

273 device_props = dbus.Interface(

274 device_obj, dbus.PROPERTIES_IFACE

275 )

276 udisks = "org.freedesktop.UDisks.Device"

277 _device_file = device_props.Get(udisks, "DeviceFile")

278 _bus = device_props.Get(udisks, "DriveConnectionInterface")

279 _speed = device_props.Get(udisks, "DriveConnectionSpeed")

280 _parent_model = ""

281 _parent_media = ""

282 _parent_vendor = ""

283

284 if device_props.Get(udisks, "DeviceIsPartition"):

285 parent_obj = self._bus.get_object(

286 "org.freedesktop.UDisks",

287 device_props.Get(udisks, "PartitionSlave"),

288 )

289 parent_props = dbus.Interface(

290 parent_obj, dbus.PROPERTIES_IFACE

291 )

292 _parent_model = parent_props.Get(udisks, "DriveModel")

293 _parent_vendor = parent_props.Get(udisks, "DriveVendor")

294 _parent_media = parent_props.Get(udisks, "DriveMedia")

295

296 if not device_props.Get(udisks, "DeviceIsDrive"):

297 device = UDisks1DriveProperties(

298 file=str(_device_file),

299 bus=str(_bus),

300 speed=int(_speed),

301 model=str(_parent_model),

302 vendor=str(_parent_vendor),

303 media=str(_parent_media),

304 )

305 existing_devices.append(device)

306

307 except dbus.DBusException:

308 pass

309

310 return existing_devices

311

312

313 def udisks2_objects_delta(old, new):

314 """

315 Compute the delta between two snapshots of udisks2 objects

316

317 The objects are encoded as {s:{s:{s:v}}} where the first dictionary maps

318 from DBus object path to a dictionary that maps from interface name to a

319 dictionary that finally maps from property name to property value.

320

321 The result is a generator of DeltaRecord objects that encodes the changes:

322 * the 'delta_dir' is either DELTA_DIR_PLUS or DELTA_DIR_MINUS

323 * the 'value' is a tuple that differs for interfaces and properties.

324 Interfaces use the format (DELTA_TYPE_IFACE, object_path, iface_name)

325 while properties use the format (DELTA_TYPE_PROP, object_path,

326 iface_name, prop_name, prop_value)

327

328 Interfaces are never "changed", they are only added or removed. Properties

329 can be changed and this is encoded as removal followed by an addition where

330 both differ only by the 'delta_dir' and the last element of the 'value'

331 tuple.

332 """

333 # Traverse all objects, old or new

334 all_object_paths = set()

335 all_object_paths |= old.keys()

336 all_object_paths |= new.keys()

337 for object_path in sorted(all_object_paths):

338 old_object = old.get(object_path, {})

339 new_object = new.get(object_path, {})

340 # Traverse all interfaces of each object, old or new

341 all_iface_names = set()

342 all_iface_names |= old_object.keys()

343 all_iface_names |= new_object.keys()

344 for iface_name in sorted(all_iface_names):

345 if iface_name not in old_object and iface_name in new_object:

346 # Report each ADDED interface

347 assert iface_name in new_object

348 delta_value = InterfaceDelta(

349 DELTA_TYPE_IFACE, object_path, iface_name

350 )

351 yield DeltaRecord(DELTA_DIR_PLUS, delta_value)

352 # Report all properties ADDED on that interface

353 for prop_name, prop_value in new_object[iface_name].items():

354 delta_value = PropertyDelta(

355 DELTA_TYPE_PROP,

356 object_path,

357 iface_name,

358 prop_name,

359 prop_value,

360 )

361 yield DeltaRecord(DELTA_DIR_PLUS, delta_value)

362 elif iface_name not in new_object and iface_name in old_object:

363 # Report each REMOVED interface

364 assert iface_name in old_object

365 delta_value = InterfaceDelta(

366 DELTA_TYPE_IFACE, object_path, iface_name

367 )

368 yield DeltaRecord(DELTA_DIR_MINUS, delta_value)

369 # Report all properties REMOVED on that interface

370 for prop_name, prop_value in old_object[iface_name].items():

371 delta_value = PropertyDelta(

372 DELTA_TYPE_PROP,

373 object_path,

374 iface_name,

375 prop_name,

376 prop_value,

377 )

378 yield DeltaRecord(DELTA_DIR_MINUS, delta_value)

379 else:

380 # Analyze properties of each interface that existed both in old

381 # and new object trees.

382 assert iface_name in new_object

383 assert iface_name in old_object

384 old_props = old_object[iface_name]

385 new_props = new_object[iface_name]

386 all_prop_names = set()

387 all_prop_names |= old_props.keys()

388 all_prop_names |= new_props.keys()

389 # Traverse all properties, old or new

390 for prop_name in sorted(all_prop_names):

391 if prop_name not in old_props and prop_name in new_props:

392 # Report each ADDED property

393 delta_value = PropertyDelta(

394 DELTA_TYPE_PROP,

395 object_path,

396 iface_name,

397 prop_name,

398 new_props[prop_name],

399 )

400 yield DeltaRecord(DELTA_DIR_PLUS, delta_value)

401 elif prop_name not in new_props and prop_name in old_props:

402 # Report each REMOVED property

403 delta_value = PropertyDelta(

404 DELTA_TYPE_PROP,

405 object_path,

406 iface_name,

407 prop_name,

408 old_props[prop_name],

409 )

410 yield DeltaRecord(DELTA_DIR_MINUS, delta_value)

411 else:

412 old_value = old_props[prop_name]

413 new_value = new_props[prop_name]

414 if old_value != new_value:

415 # Report each changed property

416 yield DeltaRecord(

417 DELTA_DIR_MINUS,

418 PropertyDelta(

419 DELTA_TYPE_PROP,

420 object_path,

421 iface_name,

422 prop_name,

423 old_value,

424 ),

425 )

426 yield DeltaRecord(

427 DELTA_DIR_PLUS,

428 PropertyDelta(

429 DELTA_TYPE_PROP,

430 object_path,

431 iface_name,

432 prop_name,

433 new_value,

434 ),

435 )

436

437

438 class UDisks2StorageDeviceListener:

439 """

440 Implementation of the storage device listener concept for UDisks2 backend.

441 Loosely modeled on the UDisks-based implementation above.

442

443 Implementation details

444 ^^^^^^^^^^^^^^^^^^^^^^

445

446 The class, once configured reacts to asynchronous events from the event

447 loop. Those are either DBus signals or GLib timeout.

448

449 The timeout, if reached, terminates the test and fails with an appropriate

450 end-user message. The user is expected to manipulate storage devices while

451 the test is running.

452

453 DBus signals (that correspond to UDisks2 DBus signals) cause callbacks into

454 this code. Each time a signal is reported "delta" is computed and verified

455 to determine if there was a successful match. The delta contains a list or

456 DeltaRecord objects that encode difference (either addition or removal) and

457 the value of the difference (interface name or interface property value).

458 This delta is computed by udisks2_objects_delta(). The delta is then passed

459 to _validate_delta() which has a chance to end the test but also prints

460 diagnostic messages in verbose mode. This is very useful for understanding

461 what the test actually sees occurring.

462

463 Insertion/removal detection strategy

464 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

465

466 Compared to initial state, the following changes objects need to be

467 detected

468

469 * At least one UDisks2 object with the following _all_ interfaces:

470 * UDisks2.Partition

471 (because we want a partitioned device)

472 * UDisks2.Block

473 (because we want that device to have a block device that users can

474 format)

475 - having IdUsage == 'filesystem'

476 (because it should not be a piece of raid or lvm)

477 - having Size > 0

478 (because it should not be and empty removable storage reader)

479 * UDisks2.Filesystem

480 (because we want to ensure that a filesystem gets mounted)

481 - having MountPoints != []

482 - as a special exception this rule is REMOVED from eSATA and SATA

483 devices as they are not automatically mounted anymore.

484

485 This object must be traceable to an UDisks.Drive object:

486 (because we need the medium to be inserted somewhere)

487 - having ConnectionBus in (desired_connection_buses)

488 - as a special exception this rule is weakened for eSATA because

489 for such devices the ConnectionBus property is empty.

490 """

491

492 # Name of the DBus interface exposed UDisks2 for various drives

493 UDISKS2_DRIVE_INTERFACE = "org.freedesktop.UDisks2.Drive"

494

495 # Name of the DBus property provided by the "Drive" interface above

496 UDISKS2_DRIVE_PROPERTY_CONNECTION_BUS = "ConnectionBus"

497

498 def __init__(

499 self,

500 system_bus,

501 loop,

502 action,

503 devices,

504 minimum_speed,

505 memorycard,

506 unmounted=False,

507 ):

508 # Store the desired minimum speed of the device in Mbit/s. The argument

509 # is passed as the number of bits per second so let's fix that.

510 self._desired_minimum_speed = minimum_speed / 10**6

511 # Compute the allowed UDisks2.Drive.ConnectionBus value based on the

512 # legacy arguments passed from the command line.

513 self._desired_connection_buses = set(

514 [map_udisks1_connection_bus(device) for device in devices]

515 )

516 # Check if we are explicitly looking for memory cards

517 self._desired_memory_card = memorycard

518 # Store information whether we also want detected, but unmounted

519 # devices too

520 self._allow_unmounted = unmounted

521 # Store the desired "delta" direction depending on

522 # whether we test for insertion or removal

523 if action == "insert":

524 self._desired_delta_dir = DELTA_DIR_PLUS

525 elif action == "remove":

526 self._desired_delta_dir = DELTA_DIR_MINUS

527 else:

528 raise ValueError("Unsupported action: {}".format(action))

529 # Store DBus bus object as we need to pass it to UDisks2 observer

530 self._bus = system_bus

531 # Store event loop object

532 self._loop = loop

533 # Setup UDisks2Observer class to track changes published by UDisks2

534 self._udisks2_observer = UDisks2Observer()

535 # Set the initial value of reference_objects.

536 # The actual value is only set once in check()

537 self._reference_objects = None

538 # As above, just initializing in init for sake of consistency

539 self._is_reference = None

540 # Setup UDisks2Model to know what the current state is. This is needed

541 # when remove events are reported as they don't carry enough state for

542 # the program to work correctly. Since UDisks2Model only applies the

543 # changes _after_ processing the signals from UDisks2Observer we can

544 # reliably check all of the properties of the removed object / device.

545 self._udisks2_model = UDisks2Model(self._udisks2_observer)

546 # Whenever anything changes call our local change handler

547 # This handler always computes the full delta (versus the

548 # reference state) and decides if we have a match or not

549 self._udisks2_model.on_change.connect(self._on_change)

550 # We may need an udev context for checking the speed of USB devices

551 self._udev_client = GUdev.Client()

552 # A snapshot of udev devices, set in check()

553 self._reference_udev_devices = None

554 # Assume the test passes, this is changed when timeout expires or when

555 # an incorrect device gets inserted.

556 self._error = False

557

558 def _dump_reference_udisks_objects(self):

559 logging.debug("Reference UDisks2 objects:")

560 for udisks2_object in self._reference_objects:

561 logging.debug(" - %s", udisks2_object)

562

563 def _dump_reference_udev_devices(self):

564 logging.debug("Reference udev devices:")

565 for udev_device in self._reference_udev_devices:

566 interconnect_speed = get_interconnect_speed(udev_device)

567 if interconnect_speed:

568 logging.debug(

569 " - %s (USB %dMBit/s)",

570 udev_device.get_device_file(),

571 interconnect_speed,

572 )

573 else:

574 logging.debug(" - %s", udev_device.get_device_file())

575

576 def check(self, timeout):

577 """

578 Run the configured test and return the result

579

580 The result is False if the test has failed. The timeout, when

581 non-zero, will make the test fail after the specified seconds have

582 elapsed without conclusive result.

583 """

584 # Setup a timeout if requested

585 if timeout > 0:

586 GObject.timeout_add_seconds(timeout, self._on_timeout_expired)

587 # Connect the observer to the bus. This will start giving us events

588 # (actually when the loop starts later below)

589 self._udisks2_observer.connect_to_bus(self._bus)

590 # Get the reference snapshot of available devices

591 self._reference_objects = copy.deepcopy(self._current_objects)

592 self._dump_reference_udisks_objects()

593 # Mark the current _reference_objects as ... reference, this is sadly

594 # needed by _summarize_changes() as it sees the snapshot _after_ a

595 # change has occurred and cannot determine if the slope of the 'edge'

596 # of the change. It is purely needed for UI in verbose mode

597 self._is_reference = True

598 # A collection of objects that we gladly ignore because we already

599 # reported on them being somehow inappropriate

600 self._ignored_objects = set()

601 # Get the reference snapshot of available udev devices

602 self._reference_udev_devices = get_udev_block_devices(

603 self._udev_client

604 )

605 self._dump_reference_udev_devices()

606 # Start the loop and wait. The loop will exit either when:

607 # 1) A proper device has been detected (either insertion or removal)

608 # 2) A timeout (optional) has expired

609 self._loop.run()

610 # Return the outcome of the test

611 return self._error

612

613 def _on_timeout_expired(self):

614 """

615 Internal function called when the timer expires.

616

617 Basically it's just here to tell the user the test failed or that the

618 user was unable to alter the device during the allowed time.

619 """

620 print("You have failed to perform the required manipulation in time")

621 # Fail the test when the timeout was reached

622 self._error = True

623 # Stop the loop now

624 self._loop.quit()

625

626 def _on_change(self):

627 """

628 Internal method called by UDisks2Model whenever a change had occurred

629 """

630 # Compute the changes that had occurred since the reference point

631 delta_records = list(self._get_delta_records())

632 # Display a summary of changes when we are done

633 self._summarize_changes(delta_records)

634 # If the changes are what we wanted stop the loop

635 matching_devices = self._get_matching_devices(delta_records)

636 if matching_devices:

637 print(

638 "Expected device manipulation complete: {}".format(

639 ", ".join(matching_devices)

640 )

641 )

642 # And call it a day

643 self._loop.quit()

644

645 def _get_matching_devices(self, delta_records):

646 """

647 Internal method called that checks if the delta records match the type

648 of device manipulation we were expecting. Only called from _on_change()

649

650 Returns a set of paths of block devices that matched

651 """

652 # Results

653 results = set()

654 # Group changes by DBus object path

655 grouped_records = collections.defaultdict(list)

656 for record in delta_records:

657 grouped_records[record.value.object_path].append(record)

658 # Create another snapshot od udev devices so that we don't do it over

659 # and over in the loop below (besides, if we did that then results

660 # could differ each time).

661 current_udev_devices = get_udev_block_devices(self._udev_client)

662 # Iterate over all UDisks2 objects and their delta records

663 for object_path, records_for_object in grouped_records.items():

664 # Skip objects we already ignored and complained about before

665 if object_path in self._ignored_objects:

666 continue

667 needs = set(("block-fs", "partition", "non-empty"))

668 if not self._allow_unmounted:

669 needs.add("mounted")

670

671 # As a special exception when the ConnectionBus is allowed to be

672 # empty, as is the case with eSATA devices, do not require the

673 # filesystem to be mounted as gvfs may choose not to mount it

674 # automatically.

675 found = set()

676 drive_object_path = None

677 object_block_device = None

678 for record in records_for_object:

679 # Skip changes opposite to the ones we need

680 if record.delta_dir != self._desired_delta_dir:

681 continue

682 # For devices with empty "ConnectionBus" property, don't

683 # require the device to be mounted

684 if (

685 record.value.iface_name == "org.freedesktop.UDisks2.Drive"

686 and record.value.delta_type == DELTA_TYPE_PROP

687 and record.value.prop_name == "ConnectionBus"

688 and record.value.prop_value == ""

689 ):

690 needs.remove("mounted")

691 # Detect block devices designated for filesystems

692 if (

693 record.value.iface_name == "org.freedesktop.UDisks2.Block"

694 and record.value.delta_type == DELTA_TYPE_PROP

695 and record.value.prop_name == "IdUsage"

696 and record.value.prop_value == "filesystem"

697 ):

698 found.add("block-fs")

699 # Memorize the block device path

700 elif (

701 record.value.iface_name == "org.freedesktop.UDisks2.Block"

702 and record.value.delta_type == DELTA_TYPE_PROP

703 and record.value.prop_name == "PreferredDevice"

704 ):

705 object_block_device = record.value.prop_value

706 # Ensure the device is a partition

707 elif (

708 record.value.iface_name

709 == "org.freedesktop.UDisks2.Partition"

710 and record.value.delta_type == DELTA_TYPE_IFACE

711 ):

712 found.add("partition")

713 # Ensure the device is not empty

714 elif (

715 record.value.iface_name == "org.freedesktop.UDisks2.Block"

716 and record.value.delta_type == DELTA_TYPE_PROP

717 and record.value.prop_name == "Size"

718 and record.value.prop_value > 0

719 ):

720 found.add("non-empty")

721 # Ensure the filesystem is mounted

722 elif (

723 record.value.iface_name

724 == "org.freedesktop.UDisks2.Filesystem"

725 and record.value.delta_type == DELTA_TYPE_PROP

726 and record.value.prop_name == "MountPoints"

727 and record.value.prop_value != []

728 ):

729 found.add("mounted")

730 # On some systems partition are reported as mounted

731 # filesystems, without 'partition' record

732 if set(["partition"]).issubset(needs):

733 needs.remove("partition")

734 # Finally memorize the drive the block device belongs to

735 elif (

736 record.value.iface_name == "org.freedesktop.UDisks2.Block"

737 and record.value.delta_type == DELTA_TYPE_PROP

738 and record.value.prop_name == "Drive"

739 ):

740 drive_object_path = record.value.prop_value

741 logging.debug(

742 "Finished analyzing %s, found: %s, needs: %s"

743 " drive_object_path: %s",

744 object_path,

745 found,

746 needs,

747 drive_object_path,

748 )

749 if not needs.issubset(found) or drive_object_path is None:

750 continue

751 # We've found our candidate, let's look at the drive it belongs

752 # to. We need to do this as some properties are associated with

753 # the drive, not the filesystem/block device and the drive may

754 # not have been inserted at all.

755 try:

756 drive_object = self._current_objects[drive_object_path]

757 except KeyError:

758 # The drive may be removed along with the device, let's check

759 # if we originally saw it

760 try:

761 drive_object = self._reference_objects[drive_object_path]

762 except KeyError:

763 logging.error(

764 "A block device belongs to a drive we could not find"

765 )

766 logging.error("missing drive: %r", drive_object_path)

767 continue

768 try:

769 drive_props = drive_object["org.freedesktop.UDisks2.Drive"]

770 except KeyError:

771 logging.error(

772 "A block device belongs to an object that is not a Drive"

773 )

774 logging.error("strange object: %r", drive_object_path)

775 continue

776 # Ensure the drive is on the appropriate bus

777 connection_bus = drive_props["ConnectionBus"]

778 if connection_bus not in self._desired_connection_buses:

779 logging.warning(

780 "The object %r belongs to drive %r that"

781 " is attached to the bus %r but but we are"

782 " looking for one of %r so it cannot match",

783 object_block_device,

784 drive_object_path,

785 connection_bus,

786 ", ".join(self._desired_connection_buses),

787 )

788 # Ignore this object so that we don't spam the user twice

789 self._ignored_objects.add(object_path)

790 continue

791 # Ensure it is a media card reader if this was explicitly requested

792 drive_is_reader = is_memory_card(

793 drive_props["Vendor"],

794 drive_props["Model"],

795 drive_props["Media"],

796 )

797 if self._desired_memory_card and not drive_is_reader:

798 logging.warning(

799 "The object %s belongs to drive %s that does not seem to"

800 " be a media reader",

801 object_block_device,

802 drive_object_path,

803 )

804 # Ignore this object so that we don't spam the user twice

805 self._ignored_objects.add(object_path)

806 continue

807 # Ensure the desired minimum speed is enforced

808 if self._desired_minimum_speed:

809 # We need to discover the speed of the UDisks2 object that is

810 # about to be matched. Sadly UDisks2 no longer supports this

811 # property so we need to poke deeper and resort to udev.

812 #

813 # The UDisks2 object that we are interested in implements a

814 # number of interfaces, most notably

815 # org.freedesktop.UDisks2.Block, that has the Device property

816 # holding the unix filesystem path (like /dev/sdb1). We already

817 # hold a reference to that as 'object_block_device'

818 #

819 # We take this as a start and attempt to locate the udev Device

820 # (don't confuse with UDisks2.Device, they are _not_ the same)

821 # that is associated with that path.

822 if self._desired_delta_dir == DELTA_DIR_PLUS:

823 # If we are looking for additions then look at _current_

824 # collection of udev devices

825 udev_devices = current_udev_devices

826 udisks2_object = self._current_objects[object_path]

827 else:

828 # If we are looking for removals then look at referece

829 # collection of udev devices

830 udev_devices = self._reference_udev_devices

831 udisks2_object = self._reference_objects[object_path]

832 try:

833 # Try to locate the corresponding udev device among the

834 # collection we've selected. Use the drive object as the

835 # key -- this looks for the drive, not partition objects!

836 udev_device = lookup_udev_device(

837 udisks2_object, udev_devices

838 )

839 except LookupError:

840 logging.error(

841 "Unable to map UDisks2 object %s to udev",

842 object_block_device,

843 )

844 # Ignore this object so that we don't spam the user twice

845 self._ignored_objects.add(object_path)

846 continue

847 interconnect_speed = get_interconnect_speed(udev_device)

848 # Now that we know the speed of the interconnect we can try to

849 # validate it against our desired speed.

850 if interconnect_speed is None:

851 logging.warning(

852 "Unable to determine interconnect speed of"

853 " device %s",

854 object_block_device,

855 )

856 # Ignore this object so that we don't spam the user twice

857 self._ignored_objects.add(object_path)

858 continue

859 elif interconnect_speed < self._desired_minimum_speed:

860 logging.warning(

861 "Device %s is connected via an interconnect that has"

862 " the speed of %dMbit/s but the required speed was"

863 " %dMbit/s",

864 object_block_device,

865 interconnect_speed,

866 self._desired_minimum_speed,

867 )

868 # Ignore this object so that we don't spam the user twice

869 self._ignored_objects.add(object_path)

870 continue

871 else:

872 logging.info(

873 "Device %s is connected via an USB"

874 " interconnect with the speed of %dMbit/s",

875 object_block_device,

876 interconnect_speed,

877 )

878 # Yay, success

879 results.add(object_block_device)

880 return results

881

882 @property

883 def _current_objects(self):

884 return self._udisks2_model.managed_objects

885

886 def _get_delta_records(self):

887 """

888 Internal method used to compute the delta between reference devices and

889 current devices. The result is a generator of DeltaRecord objects.

890 """

891 assert self._reference_objects is not None, "Only usable after check()"

892 old = self._reference_objects

893 new = self._current_objects

894 return udisks2_objects_delta(old, new)

895

896 def _summarize_changes(self, delta_records):

897 """

898 Internal method used to summarize changes (compared to reference state)

899 called whenever _on_change() gets called. Only visible in verbose mode

900 """

901 # Filter out anything but interface changes

902 flat_records = [

903 record

904 for record in delta_records

905 if record.value.delta_type == DELTA_TYPE_IFACE

906 ]

907 # Group changes by DBus object path

908 grouped_records = collections.defaultdict(list)

909 for record in flat_records:

910 grouped_records[record.value.object_path].append(record)

911 # Bail out quickly when nothing got changed

912 if not flat_records:

913 if not self._is_reference:

914 logging.info("You have returned to the reference state")

915 self._is_reference = True

916 return

917 else:

918 self._is_reference = False

919 # Iterate over grouped delta records for all objects

920 logging.info("Compared to the reference state you have:")

921 for object_path in sorted(grouped_records.keys()):

922 records_for_object = sorted(

923 grouped_records[object_path],

924 key=lambda record: record.value.iface_name,

925 )

926 # Skip any job objects as they just add noise

927 if any(

928 (

929 record.value.iface_name == "org.freedesktop.UDisks2.Job"

930 for record in records_for_object

931 )

932 ):

933 continue

934 logging.info("For object %s", object_path)

935 for record in records_for_object:

936 # Ignore property changes for now

937 if record.value.delta_type != DELTA_TYPE_IFACE:

938 continue

939 # Get the name of the interface that was affected

940 iface_name = record.value.iface_name

941 # Get the properties for that interface (for removals get the

942 # reference values, for additions get the current values)

943 if record.delta_dir == DELTA_DIR_PLUS:

944 props = self._current_objects[object_path][iface_name]

945 action = "inserted"

946 else:

947 props = self._reference_objects[object_path][iface_name]

948 action = "removed"

949 # Display some human-readable information associated with each

950 # interface change

951 if iface_name == "org.freedesktop.UDisks2.Drive":

952 logging.info("\t * %s a drive", action)

953 logging.info(

954 "\t vendor and name: %r %r",

955 props["Vendor"],

956 props["Model"],

957 )

958 logging.info("\t bus: %s", props["ConnectionBus"])

959 logging.info("\t size: %s", format_bytes(props["Size"]))

960 logging.info(

961 "\t is media card: %s",

962 is_memory_card(

963 props["Vendor"], props["Model"], props["Media"]

964 ),

965 )

966 logging.info(

967 "\t current media: %s",

968 (

969 props["Media"] or "???"

970 if props["MediaAvailable"]

971 else "N/A"

972 ),

973 )

974 elif iface_name == "org.freedesktop.UDisks2.Block":

975 logging.info("\t * %s block device", action)

976 logging.info("\t from drive: %s", props["Drive"])

977 logging.info("\t having device: %s", props["Device"])

978 logging.info(

979 "\t having usage, type and version:" " %s %s %s",

980 props["IdUsage"],

981 props["IdType"],

982 props["IdVersion"],

983 )

984 logging.info("\t having label: %s", props["IdLabel"])

985 elif iface_name == "org.freedesktop.UDisks2.PartitionTable":

986 logging.info("\t * %s partition table", action)

987 logging.info("\t having type: %r", props["Type"])

988 elif iface_name == "org.freedesktop.UDisks2.Partition":

989 logging.info("\t * %s partition", action)

990 logging.info(

991 "\t from partition table: %s", props["Table"]

992 )

993 logging.info(

994 "\t having size: %s", format_bytes(props["Size"])

995 )

996 logging.info("\t having name: %r", props["Name"])

997 elif iface_name == "org.freedesktop.UDisks2.Filesystem":

998 logging.info("\t * %s file system", action)

999 logging.info(

1000 "\t having mount points: %r", props["MountPoints"]

1001 )

1002

1003

1004 def main(argv=sys.argv[1:]):

1005 description = "Wait for the specified device to be inserted or removed."

1006 parser = argparse.ArgumentParser(description=description)

1007 parser.add_argument("action", choices=["insert", "remove"])

1008 parser.add_argument(

1009 "device",

1010 choices=["usb", "sdio", "firewire", "scsi", "ata_serial_esata"],

1011 nargs="+",

1012 )

1013 memorycard_help = (

1014 "Memory cards devices on bus other than sdio require "

1015 "this parameter to identify them as such"

1016 )

1017 parser.add_argument(

1018 "--memorycard", action="store_true", help=memorycard_help

1019 )

1020 parser.add_argument("--timeout", type=int, default=20)

1021 min_speed_help = (

1022 "Will only accept a device if its connection speed "

1023 "attribute is higher than this value "

1024 "(in bits/s)"

1025 )

1026 parser.add_argument(

1027 "--minimum_speed", "-m", help=min_speed_help, type=int, default=0

1028 )

1029 parser.add_argument(

1030 "--verbose",

1031 action="store_const",

1032 const=logging.INFO,

1033 dest="logging_level",

1034 help="Enable verbose output",

1035 )

1036 parser.add_argument(

1037 "--debug",

1038 action="store_const",

1039 const=logging.DEBUG,

1040 dest="logging_level",

1041 help="Enable debugging",

1042 )

1043 parser.add_argument(

1044 "--unmounted",

1045 action="store_true",

1046 help="Don't require drive being automounted",

1047 )

1048 parser.set_defaults(logging_level=logging.WARNING)

1049 args = parser.parse_args(argv)

1050

1051 # Configure logging as requested

1052 # XXX: This may be incorrect as logging.basicConfig() fails after any other

1053 # call to logging.log(). The proper solution is to setup a verbose logging

1054 # configuration and I didn't want to do it now.

1055 logging.basicConfig(

1056 level=args.logging_level,

1057 format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s",

1058 )

1059

1060 # Connect to the system bus, we also get the event

1061 # loop as we need it to start listening for signals.

1062 system_bus, loop = connect_to_system_bus()

1063

1064 # Check if system bus has the UDisks2 object

1065 if is_udisks2_supported(system_bus):

1066 # Construct the listener with all of the arguments provided on the

1067 # command line and the explicit system_bus, loop objects.

1068 logging.debug("Using UDisks2 interface")

1069 listener = UDisks2StorageDeviceListener(

1070 system_bus,

1071 loop,

1072 args.action,

1073 args.device,

1074 args.minimum_speed,

1075 args.memorycard,

1076 args.unmounted,

1077 )

1078 else:

1079 # Construct the listener with all of the arguments provided on the

1080 # command line and the explicit system_bus, loop objects.

1081 logging.debug("Using UDisks1 interface")

1082 listener = UDisks1StorageDeviceListener(

1083 system_bus,

1084 loop,

1085 args.action,

1086 args.device,

1087 args.minimum_speed,

1088 args.memorycard,

1089 )

1090 # Run the actual listener and wait till it either times out of discovers

1091 # the appropriate media changes

1092 print("\n\n{} NOW\n\n".format(args.action.upper()), flush=True)

1093 try:

1094 return listener.check(args.timeout)

1095 except KeyboardInterrupt:

1096 return 1

1097

1098

1099 if __name__ == "__main__":

1100 sys.exit(main())