GTK+ GUI for configuring PD Buddy Sink devices
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  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. if model.pdo.pdo_type == "fixed":
  146. type_text = "Fixed"
  147. elif model.pdo.pdo_type == "unknown":
  148. type_text = "Unknown"
  149. elif model.pdo.pdo_type == "typec_virtual":
  150. type_text = "Type-C Current"
  151. type_label = Gtk.Label(type_text)
  152. type_label.set_halign(Gtk.Align.START)
  153. box.pack_start(type_label, True, True, 0)
  154. # Voltage label
  155. if (model.pdo.pdo_type != "unknown"
  156. and model.pdo.pdo_type != "typec_virtual"):
  157. voltage_label = Gtk.Label("{:g} V".format(model.pdo.v / 1000.0))
  158. voltage_label.set_halign(Gtk.Align.END)
  159. box.pack_start(voltage_label, True, True, 0)
  160. # Right box
  161. right_box = Gtk.Box(Gtk.Orientation.HORIZONTAL, 6)
  162. right_box.set_halign(Gtk.Align.END)
  163. if model.pdo.pdo_type != "unknown":
  164. # Current label
  165. current_label = Gtk.Label("{:g} A".format(model.pdo.i / 1000.0))
  166. current_label.set_halign(Gtk.Align.END)
  167. right_box.pack_end(current_label, True, False, 0)
  168. # Over-current image(?)
  169. try:
  170. if model.pdo.peak_i > 0:
  171. oc_image = Gtk.Image.new_from_icon_name(
  172. "dialog-information-symbolic", Gtk.IconSize.BUTTON)
  173. oc_image.set_tooltip_markup(
  174. PDOListRow.oc_tooltips[model.pdo.peak_i])
  175. right_box.pack_end(oc_image, True, False, 0)
  176. except AttributeError:
  177. # If this is a typec_virtual PDO, there's no peak_i attribute.
  178. # Not a problem, so just ignore the error.
  179. pass
  180. else:
  181. # PDO value
  182. text_label = Gtk.Label()
  183. text_label.set_markup("<tt>{}</tt>".format(model.pdo))
  184. right_box.pack_end(text_label, True, False, 0)
  185. box.pack_end(right_box, True, True, 0)
  186. self.add(box)
  187. self.show_all()
  188. class Handler:
  189. def __init__(self, builder):
  190. self.builder = builder
  191. self.serial_port = None
  192. self.voltage = None
  193. self.current = None
  194. self.giveback = None
  195. self.selectlist = None
  196. def on_pdb_window_realize(self, *args):
  197. # Get the list
  198. sb = self.builder.get_object("select-box")
  199. self.selectlist = SelectList()
  200. sb.pack_start(self.selectlist, True, True, 0)
  201. liststore = SelectListStore()
  202. self.selectlist.bind_model(liststore, SelectListRow)
  203. self.selectlist.connect("row-activated", self.on_select_list_row_activated)
  204. # Add separators to the configuration page lists
  205. sc_list = self.builder.get_object("sink-config-list")
  206. sc_list.set_header_func(list_box_update_header_func, None)
  207. pd_list = self.builder.get_object("power-delivery-list")
  208. pd_list.set_header_func(list_box_update_header_func, None)
  209. def on_pdb_window_delete_event(self, *args):
  210. Gtk.main_quit(*args)
  211. def on_select_list_row_activated(self, selectlist, serport):
  212. # Get relevant widgets
  213. voltage = self.builder.get_object("voltage-combobox")
  214. current = self.builder.get_object("current-spinbutton")
  215. giveback = self.builder.get_object("giveback-toggle")
  216. pd_frame = self.builder.get_object("power-delivery-frame")
  217. output = self.builder.get_object("output-switch")
  218. cap_row = self.builder.get_object("source-cap-row")
  219. cap_warning = self.builder.get_object("source-cap-warning")
  220. cap_label = self.builder.get_object("short-source-cap-label")
  221. cap_arrow = self.builder.get_object("source-cap-arrow")
  222. self.serial_port = serport
  223. window = self.builder.get_object("pdb-window")
  224. try:
  225. with pdbuddy.Sink(self.serial_port) as pdbs:
  226. try:
  227. pdbs.load()
  228. except KeyError:
  229. # If there's no configuration, we don't want to fail. We
  230. # do want to display no configuration though
  231. self.cfg = pdbuddy.SinkConfig(
  232. status=pdbuddy.SinkStatus.VALID,
  233. flags=pdbuddy.SinkFlags.NONE, v=0, i=0)
  234. else:
  235. self.cfg = pdbs.get_tmpcfg()
  236. except OSError as e:
  237. comms_error_dialog(window, e)
  238. return
  239. self._store_device_settings()
  240. self._set_save_button_visibility()
  241. # Set giveback button state
  242. giveback.set_active(bool(self.cfg.flags & pdbuddy.SinkFlags.GIVEBACK))
  243. # Get voltage and current from device and load them into the GUI
  244. if self.cfg.v == 5000:
  245. voltage.set_active_id('voltage-five')
  246. elif self.cfg.v == 9000:
  247. voltage.set_active_id('voltage-nine')
  248. elif self.cfg.v == 12000:
  249. voltage.set_active_id('voltage-twelve')
  250. elif self.cfg.v == 15000:
  251. voltage.set_active_id('voltage-fifteen')
  252. if self.cfg.v == 20000:
  253. voltage.set_active_id('voltage-twenty')
  254. current.set_value(self.cfg.i/1000)
  255. # Set PD frame visibility and output switch state
  256. try:
  257. with pdbuddy.Sink(self.serial_port) as pdbs:
  258. output.set_state(pdbs.output)
  259. except KeyError:
  260. pd_frame.set_visible(False)
  261. else:
  262. pd_frame.set_visible(True)
  263. # TODO: do these next things repeatedly
  264. # Get the Source_Capabilities
  265. with pdbuddy.Sink(self.serial_port) as pdbs:
  266. caps = pdbs.get_source_cap()
  267. # Update the warning icon
  268. cap_warning.set_visible(not pdbuddy.follows_power_rules(caps))
  269. # Update the text in the capability label
  270. if caps:
  271. cap_label.set_text('{:g} W'.format(pdbuddy.calculate_pdp(caps)))
  272. else:
  273. cap_label.set_text('None')
  274. # Make the row insensitive if there are no capabilities
  275. cap_row.set_activatable(caps)
  276. cap_arrow.set_visible(caps)
  277. # Show the Sink page
  278. hst = self.builder.get_object("header-stack")
  279. hsink = self.builder.get_object("header-sink")
  280. hsink.set_title('{} {} {}'.format(serport.manufacturer,
  281. serport.product,
  282. serport.serial_number))
  283. hsink.set_subtitle(serport.device)
  284. hst.set_visible_child(hsink)
  285. st = self.builder.get_object("stack")
  286. sink = self.builder.get_object("sink")
  287. st.set_visible_child(sink)
  288. # Ping the Sink repeatedly
  289. GLib.timeout_add(1000, self._ping)
  290. def _ping(self):
  291. """Ping the device we're configuring, showing to the list on failure"""
  292. if self.serial_port is None:
  293. self.selectlist.reload()
  294. self.on_header_sink_back_clicked(None)
  295. return False
  296. try:
  297. with pdbuddy.Sink(self.serial_port) as pdbs:
  298. pdbs.send_command("")
  299. return True
  300. except:
  301. self.selectlist.reload()
  302. self.on_header_sink_back_clicked(None)
  303. return False
  304. def on_header_sink_back_clicked(self, data):
  305. self.serial_port = None
  306. # Show the Select page
  307. hst = self.builder.get_object("header-stack")
  308. hselect = self.builder.get_object("header-select")
  309. hst.set_visible_child(hselect)
  310. st = self.builder.get_object("stack")
  311. select = self.builder.get_object("select")
  312. st.set_visible_child(select)
  313. def on_sink_save_clicked(self, button):
  314. window = self.builder.get_object("pdb-window")
  315. try:
  316. with pdbuddy.Sink(self.serial_port) as pdbs:
  317. pdbs.set_tmpcfg(self.cfg)
  318. pdbs.write()
  319. self._store_device_settings()
  320. self._set_save_button_visibility()
  321. except OSError as e:
  322. comms_error_dialog(window, e)
  323. self.on_header_sink_back_clicked(None)
  324. def _store_device_settings(self):
  325. """Store the settings that were loaded from the device"""
  326. self.cfg_clean = self.cfg
  327. def _set_save_button_visibility(self):
  328. """Show the save button if there are new settings to save"""
  329. # Get relevant widgets
  330. rev = self.builder.get_object("sink-save-revealer")
  331. # Set visibility
  332. rev.set_reveal_child(self.cfg != self.cfg_clean)
  333. def on_voltage_combobox_changed(self, combo):
  334. self.cfg = self.cfg._replace(v=int(combo.get_active_text()) * 1000)
  335. self._set_save_button_visibility()
  336. def on_current_spinbutton_changed(self, spin):
  337. self.cfg = self.cfg._replace(i=int(spin.get_value() * 1000))
  338. self._set_save_button_visibility()
  339. def on_giveback_toggle_toggled(self, toggle):
  340. if toggle.get_active():
  341. self.cfg = self.cfg._replace(flags=self.cfg.flags|pdbuddy.SinkFlags.GIVEBACK)
  342. else:
  343. self.cfg = self.cfg._replace(flags=self.cfg.flags&~pdbuddy.SinkFlags.GIVEBACK)
  344. self._set_save_button_visibility()
  345. def on_output_switch_state_set(self, switch, state):
  346. with pdbuddy.Sink(self.serial_port) as pdbs:
  347. pdbs.output = state
  348. def on_source_cap_row_activated(self, box, row):
  349. # Find which row was clicked
  350. sc_row = self.builder.get_object("source-cap-row")
  351. if row != sc_row:
  352. # If it's not the source-cap-row, leave
  353. return
  354. # Get the source capabilities
  355. with pdbuddy.Sink(self.serial_port) as pdbs:
  356. caps = pdbs.get_source_cap()
  357. if not caps:
  358. # If there are no capabilities, don't show a dialog
  359. return
  360. # Create the dialog
  361. window = self.builder.get_object("pdb-window")
  362. dialog_builder = Gtk.Builder.new_from_file("data/src-cap-dialog.ui")
  363. dialog = dialog_builder.get_object("src-cap-dialog")
  364. dialog.set_transient_for(window)
  365. # Populate PD Power
  366. d_power = dialog_builder.get_object("power-label")
  367. d_power.set_text("{:g} W".format(pdbuddy.calculate_pdp(caps)))
  368. # Warning icon
  369. cap_warning = dialog_builder.get_object("source-cap-warning")
  370. cap_warning.set_visible(not pdbuddy.follows_power_rules(caps))
  371. # Populate Information
  372. d_info_header = dialog_builder.get_object("info-header")
  373. d_info = dialog_builder.get_object("info-label")
  374. # Make the string to display
  375. info_str = ""
  376. try:
  377. if caps[0].dual_role_pwr:
  378. info_str += "Dual-Role Power\n"
  379. if caps[0].usb_suspend:
  380. info_str += "USB Suspend Supported\n"
  381. if caps[0].unconstrained_pwr:
  382. info_str += "Unconstrained Power\n"
  383. if caps[0].usb_comms:
  384. info_str += "USB Communications Capable\n"
  385. if caps[0].dual_role_data:
  386. info_str += "Dual-Role Data\n"
  387. info_str = info_str[:-1]
  388. except AttributeError:
  389. # If we have a typec_virtual PDO, there will be AttributeErrors
  390. # from the above. Not a problem, so just pass.
  391. pass
  392. # Set the text and label visibility
  393. d_info.set_text(info_str)
  394. d_info_header.set_visible(info_str)
  395. d_info.set_visible(info_str)
  396. # PDO list
  397. d_list = dialog_builder.get_object("src-cap-list")
  398. d_list.set_header_func(list_box_update_header_func, None)
  399. model = PDOListStore()
  400. d_list.bind_model(model, PDOListRow)
  401. model.update_items(caps)
  402. # Show the dialog
  403. dialog.run()
  404. dialog.destroy()
  405. class Application(Gtk.Application):
  406. def __init__(self, *args, **kwargs):
  407. super().__init__(*args, application_id="com.clayhobbs.pd-buddy-gtk",
  408. **kwargs)
  409. self.window = None
  410. def do_startup(self):
  411. Gtk.Application.do_startup(self)
  412. self.builder = Gtk.Builder.new_from_file("data/pd-buddy-gtk.ui")
  413. self.builder.connect_signals(Handler(self.builder))
  414. def do_activate(self):
  415. # We only allow a single window and raise any existing ones
  416. if not self.window:
  417. # Windows are associated with the application
  418. # when the last one is closed the application shuts down
  419. self.window = self.builder.get_object("pdb-window")
  420. self.add_window(self.window)
  421. self.window.set_wmclass("PD Buddy Configuration",
  422. "PD Buddy Configuration")
  423. self.window.present()
  424. def run():
  425. app = Application()
  426. app.run(sys.argv)
  427. if __name__ == "__main__":
  428. run()