GTK+ GUI for configuring PD Buddy Sink devices
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

pd-buddy-gtk.py 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. #!/usr/bin/env python3
  2. import sys
  3. import pdbuddy
  4. import gi
  5. gi.require_version('Gtk', '3.0')
  6. from gi.repository import Gtk, Gio, GObject, GLib
  7. def comms_error_dialog(parent, e):
  8. dialog = Gtk.MessageDialog(window, 0, Gtk.MessageType.ERROR,
  9. Gtk.ButtonsType.CLOSE, "Error communicating with device")
  10. dialog.format_secondary_text(e.strerror)
  11. dialog.run()
  12. dialog.destroy()
  13. class SelectListRowModel(GObject.GObject):
  14. def __init__(self, serport):
  15. GObject.GObject.__init__(self)
  16. self.serport = serport
  17. class SelectListStore(Gio.ListStore):
  18. def update_items(self):
  19. # Get a list of serial ports
  20. serports = list(pdbuddy.Sink.get_devices())
  21. # Mark ports to remove or add
  22. remove_list = []
  23. list_len = self.get_n_items()
  24. for i in range(list_len):
  25. remove = True
  26. for j in range(len(serports)):
  27. if serports[j] is not None and self.get_item(i).serport == serports[j]:
  28. serports[j] = None
  29. remove = False
  30. if remove:
  31. remove_list.append(i)
  32. # Remove the missing ones
  33. for i in remove_list:
  34. self.remove(i)
  35. # Add any new ports
  36. for port in serports:
  37. if port is not None:
  38. self.append(SelectListRowModel(port))
  39. def list_box_update_header_func(row, before, data):
  40. """Add a separator header to all rows but the first one"""
  41. if before is None:
  42. row.set_header(None)
  43. return
  44. current = row.get_header()
  45. if current is None:
  46. current = Gtk.Separator.new(Gtk.Orientation.HORIZONTAL)
  47. row.set_header(current)
  48. class SelectList(Gtk.Box):
  49. __gsignals__ = {
  50. 'row-activated': (GObject.SIGNAL_RUN_FIRST, None,
  51. (object,))
  52. }
  53. def __init__(self):
  54. Gtk.Box.__init__(self)
  55. self._model = None
  56. self._builder = Gtk.Builder()
  57. self._builder.add_from_file("data/select-stack.ui")
  58. self._builder.connect_signals(self)
  59. sl = self._builder.get_object("select-list")
  60. # Add separators to the list
  61. sl.set_header_func(list_box_update_header_func, None)
  62. self.pack_start(self._builder.get_object("select-stack"), True, True, 0)
  63. self.show_all()
  64. def bind_model(self, model, func):
  65. self._builder.get_object("select-list").bind_model(model, func)
  66. self._model = model
  67. self.reload()
  68. GLib.timeout_add(1000, self.reload)
  69. def reload(self):
  70. self._model.update_items()
  71. # Set the visible child
  72. stack = self._builder.get_object("select-stack")
  73. if self._model.get_n_items():
  74. stack.set_visible_child(self._builder.get_object("select-frame"))
  75. else:
  76. stack.set_visible_child(self._builder.get_object("select-none"))
  77. return True
  78. def on_select_list_row_activated(self, box, row):
  79. self.emit("row-activated", row.model.serport)
  80. class SelectListRow(Gtk.ListBoxRow):
  81. def __init__(self, model):
  82. Gtk.EventBox.__init__(self)
  83. self.model = model
  84. self._builder = Gtk.Builder()
  85. self._builder.add_from_file("data/select-list-row.ui")
  86. self._builder.connect_signals(self)
  87. name = self._builder.get_object("name")
  88. name.set_text('{} {} {}'.format(self.model.serport.manufacturer,
  89. self.model.serport.product,
  90. self.model.serport.serial_number))
  91. device = self._builder.get_object("device")
  92. device.set_text(self.model.serport.device)
  93. self.add(self._builder.get_object("grid"))
  94. self.show_all()
  95. def on_identify_clicked(self, button):
  96. window = self.get_toplevel()
  97. try:
  98. with pdbuddy.Sink(self.model.serport) as pdbs:
  99. pdbs.identify()
  100. except OSError as e:
  101. comms_error_dialog(window, e)
  102. return
  103. class PDOListRowModel(GObject.GObject):
  104. def __init__(self, pdo):
  105. GObject.GObject.__init__(self)
  106. self.pdo = pdo
  107. class PDOListStore(Gio.ListStore):
  108. def update_items(self, pdo_list):
  109. # Clear the list
  110. self.remove_all()
  111. # Add everything from the new list
  112. for pdo in pdo_list:
  113. self.append(PDOListRowModel(pdo))
  114. class PDOListRow(Gtk.ListBoxRow):
  115. oc_tooltips = [
  116. "I<sub>Peak</sub> = I<sub>OC</sub> (default)",
  117. """Overload Capabilities:
  118. 1. I<sub>Peak</sub> = 150% I<sub>OC</sub> for 1 ms @ 5% duty cycle (I<sub>Low</sub> = 97% I<sub>OC</sub> for 19 ms)
  119. 2. I<sub>Peak</sub> = 125% I<sub>OC</sub> for 2 ms @ 10% duty cycle (I<sub>Low</sub> = 97% I<sub>OC</sub> for 18 ms)
  120. 3. I<sub>Peak</sub> = 110% I<sub>OC</sub> for 10 ms @ 50% duty cycle (I<sub>Low</sub> = 90% I<sub>OC</sub> for 10 ms)""",
  121. """Overload Capabilities:
  122. 1. I<sub>Peak</sub> = 200% I<sub>OC</sub> for 1 ms @ 5% duty cycle (I<sub>Low</sub> = 95% I<sub>OC</sub> for 19 ms)
  123. 2. I<sub>Peak</sub> = 150% I<sub>OC</sub> for 2 ms @ 10% duty cycle (I<sub>Low</sub> = 94% I<sub>OC</sub> for 18 ms)
  124. 3. I<sub>Peak</sub> = 125% I<sub>OC</sub> for 10 ms @ 50% duty cycle (I<sub>Low</sub> = 75% I<sub>OC</sub> for 10 ms)""",
  125. """Overload Capabilities:
  126. 1. I<sub>Peak</sub> = 200% I<sub>OC</sub> for 1 ms @ 5% duty cycle (I<sub>Low</sub> = 95% I<sub>OC</sub> for 19 ms)
  127. 2. I<sub>Peak</sub> = 175% I<sub>OC</sub> for 2 ms @ 10% duty cycle (I<sub>Low</sub> = 92% I<sub>OC</sub> for 18 ms)
  128. 3. I<sub>Peak</sub> = 150% I<sub>OC</sub> for 10 ms @ 50% duty cycle (I<sub>Low</sub> = 50% I<sub>OC</sub> for 10 ms)"""
  129. ]
  130. def __init__(self, model):
  131. Gtk.ListBoxRow.__init__(self)
  132. self.model = model
  133. self.set_activatable(False)
  134. self.set_selectable(False)
  135. self.set_can_focus(False)
  136. # Make the widgets and populate them with info from the model
  137. # Main box
  138. box = Gtk.Box(Gtk.Orientation.HORIZONTAL, 12)
  139. box.set_homogeneous(True)
  140. box.set_margin_left(12)
  141. box.set_margin_right(12)
  142. box.set_margin_top(6)
  143. box.set_margin_bottom(6)
  144. # Type label
  145. type_label = Gtk.Label(model.pdo.pdo_type.capitalize())
  146. type_label.set_halign(Gtk.Align.START)
  147. box.pack_start(type_label, True, True, 0)
  148. # Voltage label
  149. if model.pdo.pdo_type != "unknown":
  150. voltage_label = Gtk.Label("{:g} V".format(model.pdo.v / 1000.0))
  151. voltage_label.set_halign(Gtk.Align.END)
  152. box.pack_start(voltage_label, True, True, 0)
  153. # Right box
  154. right_box = Gtk.Box(Gtk.Orientation.HORIZONTAL, 6)
  155. right_box.set_halign(Gtk.Align.END)
  156. if model.pdo.pdo_type != "unknown":
  157. # Current label
  158. current_label = Gtk.Label("{:g} A".format(model.pdo.i / 1000.0))
  159. current_label.set_halign(Gtk.Align.END)
  160. right_box.pack_end(current_label, True, False, 0)
  161. # Over-current image(?)
  162. if model.pdo.peak_i > 0:
  163. oc_image = Gtk.Image.new_from_icon_name(
  164. "dialog-information-symbolic", Gtk.IconSize.BUTTON)
  165. oc_image.set_tooltip_markup(
  166. PDOListRow.oc_tooltips[model.pdo.peak_i])
  167. right_box.pack_end(oc_image, True, False, 0)
  168. else:
  169. # PDO value
  170. text_label = Gtk.Label()
  171. text_label.set_markup("<tt>{}</tt>".format(model.pdo))
  172. right_box.pack_end(text_label, True, False, 0)
  173. box.pack_end(right_box, True, True, 0)
  174. self.add(box)
  175. self.show_all()
  176. class Handler:
  177. def __init__(self, builder):
  178. self.builder = builder
  179. self.serial_port = None
  180. self.voltage = None
  181. self.current = None
  182. self.giveback = None
  183. self.selectlist = None
  184. def on_pdb_window_realize(self, *args):
  185. # Get the list
  186. sb = self.builder.get_object("select-box")
  187. self.selectlist = SelectList()
  188. sb.pack_start(self.selectlist, True, True, 0)
  189. liststore = SelectListStore()
  190. self.selectlist.bind_model(liststore, SelectListRow)
  191. self.selectlist.connect("row-activated", self.on_select_list_row_activated)
  192. # Add separators to the configuration page lists
  193. sc_list = self.builder.get_object("sink-config-list")
  194. sc_list.set_header_func(list_box_update_header_func, None)
  195. pd_list = self.builder.get_object("power-delivery-list")
  196. pd_list.set_header_func(list_box_update_header_func, None)
  197. def on_pdb_window_delete_event(self, *args):
  198. Gtk.main_quit(*args)
  199. def on_select_list_row_activated(self, selectlist, serport):
  200. # Get relevant widgets
  201. voltage = self.builder.get_object("voltage-combobox")
  202. current = self.builder.get_object("current-spinbutton")
  203. giveback = self.builder.get_object("giveback-toggle")
  204. pd_frame = self.builder.get_object("power-delivery-frame")
  205. output = self.builder.get_object("output-switch")
  206. cap_row = self.builder.get_object("source-cap-row")
  207. cap_warning = self.builder.get_object("source-cap-warning")
  208. cap_label = self.builder.get_object("short-source-cap-label")
  209. cap_arrow = self.builder.get_object("source-cap-arrow")
  210. self.serial_port = serport
  211. window = self.builder.get_object("pdb-window")
  212. try:
  213. with pdbuddy.Sink(self.serial_port) as pdbs:
  214. try:
  215. pdbs.load()
  216. except KeyError:
  217. # If there's no configuration, we don't want to fail. We
  218. # do want to display no configuration though
  219. self.cfg = pdbuddy.SinkConfig(
  220. status=pdbuddy.SinkStatus.VALID,
  221. flags=pdbuddy.SinkFlags.NONE, v=0, i=0)
  222. else:
  223. self.cfg = pdbs.get_tmpcfg()
  224. except OSError as e:
  225. comms_error_dialog(window, e)
  226. return
  227. self._store_device_settings()
  228. self._set_save_button_visibility()
  229. # Set giveback button state
  230. giveback.set_active(bool(self.cfg.flags & pdbuddy.SinkFlags.GIVEBACK))
  231. # Get voltage and current from device and load them into the GUI
  232. if self.cfg.v == 5000:
  233. voltage.set_active_id('voltage-five')
  234. elif self.cfg.v == 9000:
  235. voltage.set_active_id('voltage-nine')
  236. elif self.cfg.v == 12000:
  237. voltage.set_active_id('voltage-twelve')
  238. elif self.cfg.v == 15000:
  239. voltage.set_active_id('voltage-fifteen')
  240. if self.cfg.v == 20000:
  241. voltage.set_active_id('voltage-twenty')
  242. current.set_value(self.cfg.i/1000)
  243. # Set PD frame visibility and output switch state
  244. try:
  245. with pdbuddy.Sink(self.serial_port) as pdbs:
  246. output.set_state(pdbs.output)
  247. except KeyError:
  248. pd_frame.set_visible(False)
  249. else:
  250. pd_frame.set_visible(True)
  251. # TODO: do these next things repeatedly
  252. # Get the Source_Capabilities
  253. with pdbuddy.Sink(self.serial_port) as pdbs:
  254. caps = pdbs.get_source_cap()
  255. # Update the warning icon
  256. cap_warning.set_visible(not pdbuddy.follows_power_rules(caps))
  257. # Update the text in the capability label
  258. if caps:
  259. cap_label.set_text('{:g} W'.format(pdbuddy.calculate_pdp(caps)))
  260. else:
  261. cap_label.set_text('None')
  262. # Make the row insensitive if there are no capabilities
  263. cap_row.set_activatable(caps)
  264. cap_arrow.set_visible(caps)
  265. # Show the Sink page
  266. hst = self.builder.get_object("header-stack")
  267. hsink = self.builder.get_object("header-sink")
  268. hsink.set_title('{} {} {}'.format(serport.manufacturer,
  269. serport.product,
  270. serport.serial_number))
  271. hsink.set_subtitle(serport.device)
  272. hst.set_visible_child(hsink)
  273. st = self.builder.get_object("stack")
  274. sink = self.builder.get_object("sink")
  275. st.set_visible_child(sink)
  276. # Ping the Sink repeatedly
  277. GLib.timeout_add(1000, self._ping)
  278. def _ping(self):
  279. """Ping the device we're configuring, showing to the list on failure"""
  280. if self.serial_port is None:
  281. self.selectlist.reload()
  282. self.on_header_sink_back_clicked(None)
  283. return False
  284. try:
  285. with pdbuddy.Sink(self.serial_port) as pdbs:
  286. pdbs.send_command("")
  287. return True
  288. except:
  289. self.selectlist.reload()
  290. self.on_header_sink_back_clicked(None)
  291. return False
  292. def on_header_sink_back_clicked(self, data):
  293. self.serial_port = None
  294. # Show the Select page
  295. hst = self.builder.get_object("header-stack")
  296. hselect = self.builder.get_object("header-select")
  297. hst.set_visible_child(hselect)
  298. st = self.builder.get_object("stack")
  299. select = self.builder.get_object("select")
  300. st.set_visible_child(select)
  301. def on_sink_save_clicked(self, button):
  302. window = self.builder.get_object("pdb-window")
  303. try:
  304. with pdbuddy.Sink(self.serial_port) as pdbs:
  305. pdbs.set_tmpcfg(self.cfg)
  306. pdbs.write()
  307. self._store_device_settings()
  308. self._set_save_button_visibility()
  309. except OSError as e:
  310. comms_error_dialog(window, e)
  311. self.on_header_sink_back_clicked(None)
  312. def _store_device_settings(self):
  313. """Store the settings that were loaded from the device"""
  314. self.cfg_clean = self.cfg
  315. def _set_save_button_visibility(self):
  316. """Show the save button if there are new settings to save"""
  317. # Get relevant widgets
  318. rev = self.builder.get_object("sink-save-revealer")
  319. # Set visibility
  320. rev.set_reveal_child(self.cfg != self.cfg_clean)
  321. def on_voltage_combobox_changed(self, combo):
  322. self.cfg = self.cfg._replace(v=int(combo.get_active_text()) * 1000)
  323. self._set_save_button_visibility()
  324. def on_current_spinbutton_changed(self, spin):
  325. self.cfg = self.cfg._replace(i=int(spin.get_value() * 1000))
  326. self._set_save_button_visibility()
  327. def on_giveback_toggle_toggled(self, toggle):
  328. if toggle.get_active():
  329. self.cfg = self.cfg._replace(flags=self.cfg.flags|pdbuddy.SinkFlags.GIVEBACK)
  330. else:
  331. self.cfg = self.cfg._replace(flags=self.cfg.flags&~pdbuddy.SinkFlags.GIVEBACK)
  332. self._set_save_button_visibility()
  333. def on_output_switch_state_set(self, switch, state):
  334. with pdbuddy.Sink(self.serial_port) as pdbs:
  335. pdbs.output = state
  336. def on_source_cap_row_activated(self, box, row):
  337. # Find which row was clicked
  338. sc_row = self.builder.get_object("source-cap-row")
  339. if row != sc_row:
  340. # If it's not the source-cap-row, leave
  341. return
  342. # Get the source capabilities
  343. with pdbuddy.Sink(self.serial_port) as pdbs:
  344. caps = pdbs.get_source_cap()
  345. if not caps:
  346. # If there are no capabilities, don't show a dialog
  347. return
  348. # Create the dialog
  349. window = self.builder.get_object("pdb-window")
  350. dialog_builder = Gtk.Builder.new_from_file("data/src-cap-dialog.ui")
  351. dialog = dialog_builder.get_object("src-cap-dialog")
  352. dialog.set_transient_for(window)
  353. # Populate PD Power
  354. d_power = dialog_builder.get_object("power-label")
  355. d_power.set_text("{:g} W".format(pdbuddy.calculate_pdp(caps)))
  356. # Warning icon
  357. cap_warning = dialog_builder.get_object("source-cap-warning")
  358. cap_warning.set_visible(not pdbuddy.follows_power_rules(caps))
  359. # Populate Information
  360. d_info_header = dialog_builder.get_object("info-header")
  361. d_info = dialog_builder.get_object("info-label")
  362. # Make the string to display
  363. info_str = ""
  364. if caps[0].dual_role_pwr:
  365. info_str += "Dual-Role Power\n"
  366. if caps[0].usb_suspend:
  367. info_str += "USB Suspend Supported\n"
  368. if caps[0].unconstrained_pwr:
  369. info_str += "Unconstrained Power\n"
  370. if caps[0].usb_comms:
  371. info_str += "USB Communications Capable\n"
  372. if caps[0].dual_role_data:
  373. info_str += "Dual-Role Data\n"
  374. info_str = info_str[:-1]
  375. # Set the text and label visibility
  376. d_info.set_text(info_str)
  377. d_info_header.set_visible(info_str)
  378. d_info.set_visible(info_str)
  379. # PDO list
  380. d_list = dialog_builder.get_object("src-cap-list")
  381. d_list.set_header_func(list_box_update_header_func, None)
  382. model = PDOListStore()
  383. d_list.bind_model(model, PDOListRow)
  384. model.update_items(caps)
  385. # Show the dialog
  386. dialog.run()
  387. dialog.destroy()
  388. class Application(Gtk.Application):
  389. def __init__(self, *args, **kwargs):
  390. super().__init__(*args, application_id="com.clayhobbs.pd-buddy-gtk",
  391. **kwargs)
  392. self.window = None
  393. def do_startup(self):
  394. Gtk.Application.do_startup(self)
  395. self.builder = Gtk.Builder.new_from_file("data/pd-buddy-gtk.ui")
  396. self.builder.connect_signals(Handler(self.builder))
  397. def do_activate(self):
  398. # We only allow a single window and raise any existing ones
  399. if not self.window:
  400. # Windows are associated with the application
  401. # when the last one is closed the application shuts down
  402. self.window = self.builder.get_object("pdb-window")
  403. self.add_window(self.window)
  404. self.window.set_wmclass("PD Buddy Configuration",
  405. "PD Buddy Configuration")
  406. self.window.present()
  407. def run():
  408. app = Application()
  409. app.run(sys.argv)
  410. if __name__ == "__main__":
  411. run()