From 18e7899df4cd87be41215e7719b43ac2fb305428 Mon Sep 17 00:00:00 2001 From: Giancarlo Razzolini Date: Thu, 13 Jun 2024 14:10:08 -0300 Subject: [PATCH] Introduces a plugin to display information about Galaxy Buds devices using (#860) the earbuds tool from https://github.com/JojiiOfficial/LiveBudsCli * Displays per bud battery level * Displays case battery level * Displays placement status * Displays and sets both Ambient sound (AMB) and Active noise control (ANC) * Has commands to connect/disconnect * Dynamic coloring based on battery level * Equalizer setting * Touchpad lock --- i3pystatus/buds.py | 237 +++++++++++++++++++ tests/test_buds.json | 48 ++++ tests/test_buds.py | 542 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 827 insertions(+) create mode 100644 i3pystatus/buds.py create mode 100644 tests/test_buds.json create mode 100644 tests/test_buds.py diff --git a/i3pystatus/buds.py b/i3pystatus/buds.py new file mode 100644 index 00000000..ff17e19a --- /dev/null +++ b/i3pystatus/buds.py @@ -0,0 +1,237 @@ +from enum import Enum, IntEnum +from json import JSONDecodeError, loads +from i3pystatus import IntervalModule +from i3pystatus.core.command import run_through_shell +from i3pystatus.core.color import ColorRangeModule + + +class BudsEqualizer(Enum): + off = 0 + bass = 1 + soft = 2 + dynamic = 3 + clear = 4 + treble = 5 + + +class BudsPlacementStatus(IntEnum): + wearing = 1 + idle = 2 + case = 3 + + +class Buds(IntervalModule, ColorRangeModule): + earbuds_binary = "earbuds" + + """ + Displays information about Galaxy Buds devices + + Requires the earbuds tool from https://github.com/JojiiOfficial/LiveBudsCli + + .. rubric :: Available formatters + * {amb} Displays the current ambient sound control status. + * {anc} Displays the current active noise control status. + * {battery} Displays combined battery level for left and right. + If both are at the same level, it simply returns the battery level. + If they have different levels and the drift threshold is enabled, provided + they do not exceed the threshold, display the smaller level. + If they have different battery levels, it returns both levels, if the threshold + is exceeded. + * `{left_battery}` Displays the left bud battery level. + * `{right_battery}` Displays the right bud battery level. + * `{battery_case} Displays the case battery level, if one of the buds is on the case. + * `{device_model}` The model of the device. + * `{equalizer} Displays current equalizer setting, only if the equalizer is on. + * `{placement_left}` A placement indicator for the left bud, if it's on the (C)ase, (I)dle or being (W)ear. + * `{placement_right}` A placement indicator for the right bud, if it's on the (C)ase, (I)dle or being (W)ear. + * `{touchpad}` Displays if the touchpad is locked, and only if it is locked. A T(ouchpad)L(ocked) string indicates + the touchpad is locked. + """ + + settings = ( + ("format", "Format string used for output"), + ("interval", "Interval to run the module"), + ("hide_no_device", "Hide the output if no device is connected"), + ("battery_drift_threshold", "Drift threshold."), + ("use_battery_drift_threshold", "Whether to display combined or separate levels, based on drift"), + ("connected_color", "Output color for when the device is connected"), + ("disconnected_color", "Output color for when the device is disconnected"), + ("dynamic_color", "Output color based on battery level. Overrides connected_color"), + ("start_color", "Hex or English name for start of color range, eg '#00FF00' or 'green'"), + ("end_color", "Hex or English name for end of color range, eg '#FF0000' or 'red'") + ) + + format = ( + "{device_model} " + "L{placement_left}" + "{battery}" + "R{placement_right}" + "{battery_case}" + "{amb}" + "{anc}" + "{equalizer}" + "{touchpad}" + ) + hide_no_device = False + battery_limit = 100 + battery_drift_threshold = 3 + use_battery_drift_threshold = True + + connected_color = "#00FF00" + disconnected_color = "#FFFFFF" + dynamic_color = True + colors = [] + + on_leftclick = 'toggle_anc' + on_rightclick = 'toggle_amb' + on_doubleleftclick = 'connect' + on_doublerightclick = 'disconnect' + on_middleclick = ['equalizer_set', BudsEqualizer.off] + on_downscroll = ['equalizer_set', -1] + on_upscroll = ['equalizer_set', +1] + on_doublemiddleclick = 'restart_daemon' + on_doubleupscroll = ['touchpad_set', 'true'] + on_doubledownscroll = ['touchpad_set', 'false'] + + def init(self): + if not self.dynamic_color: + self.end_color = self.start_color = self.connected_color + # battery discharges from battery_limit to 0 + self.colors = self.get_hex_color_range(self.end_color, self.start_color, self.battery_limit) + + def run(self): + try: + status = loads(run_through_shell(f"{self.earbuds_binary} status -o json -q").out) + except JSONDecodeError: + self.output = None + else: + payload = status.get("payload") + if payload: + amb = payload.get("ambient_sound_enabled") + anc = payload.get("noise_reduction") + left_battery = payload.get("batt_left") + right_battery = payload.get("batt_right") + equalizer_type = BudsEqualizer(payload.get("equalizer_type", 0)) + placement_left = payload.get("placement_left") + placement_right = payload.get("placement_right") + tab_lock_status = payload.get("tab_lock_status") + # determine touchpad lock status + touchpad = "" + if tab_lock_status: + touch_an_hold_on = tab_lock_status.get("touch_an_hold_on") + tap_on = tab_lock_status.get("tap_on") + if not touch_an_hold_on and not tap_on: + touchpad = " TL" + + # determine battery level and color to display + battery_display = f"{left_battery} {right_battery}" + color = self.connected_color + combined_level = min(left_battery, right_battery) + # if one bud has battery depleted, invert the logic. + if left_battery == 0 or right_battery == 0: + combined_level = max(left_battery, right_battery) + if self.use_battery_drift_threshold: + drift = abs(left_battery - right_battery) + # only use drift if buds aren't on case, otherwise show both. + if drift <= self.battery_drift_threshold and not ( + placement_left == BudsPlacementStatus.case or + placement_right == BudsPlacementStatus.case + ): + + battery_display = f"{combined_level}" + + if self.dynamic_color: + color = self.get_gradient( + combined_level, + self.colors, + self.battery_limit + ) + + fdict = { + "amb": " AMB" if amb else "", + "anc": " ANC" if anc else "", + "battery": battery_display, + "left_battery": left_battery, + "right_battery": right_battery, + "battery_case": + f' {payload.get("batt_case")}C' if placement_left == BudsPlacementStatus.case + or placement_right == BudsPlacementStatus.case else "", + "device_model": payload.get("model"), + "equalizer": "" if equalizer_type == BudsEqualizer.off else f" {equalizer_type.name.capitalize()}", + "placement_left": self.translate_placement(placement_left), + "placement_right": self.translate_placement(placement_right), + "touchpad": touchpad + } + + self.output = { + "full_text": self.format.format(**fdict), + "color": color + } + + return payload + else: + if not self.hide_no_device: + self.output = { + "full_text": "Disconnected", + "color": self.disconnected_color + } + else: + self.output = None + + return + + def connect(self): + run_through_shell(f"{self.earbuds_binary} connect") + + def disconnect(self): + run_through_shell(f"{self.earbuds_binary} disconnect") + + def equalizer_set(self, adjustment): + payload = self.run() + if payload: + current_eq = int(payload.get("equalizer_type", 0)) # Default to 0 if not found + + if isinstance(adjustment, BudsEqualizer): + new_eq_value = adjustment.value + else: # Adjustment is -1 or +1 + # Calculate new equalizer setting, ensuring it wraps correctly within bounds + new_eq_value = (current_eq + adjustment) % len(BudsEqualizer) + + # Find the enum member corresponding to the new equalizer value + new_eq_setting = BudsEqualizer(new_eq_value) + + # Execute the command with the new equalizer setting + run_through_shell(f"{self.earbuds_binary} set equalizer {new_eq_setting.name}") + + def restart_daemon(self): + run_through_shell(f"{self.earbuds_binary} -kd") + + def toggle_amb(self): + payload = self.run() + if payload: + amb = payload.get("ambient_sound_enabled") + if amb: + run_through_shell(f"{self.earbuds_binary} set ambientsound 0") + else: + run_through_shell(f"{self.earbuds_binary} set ambientsound 1") + + def toggle_anc(self): + payload = self.run() + if payload: + anc = payload.get("noise_reduction") + if anc: + run_through_shell(f"{self.earbuds_binary} set anc false") + else: + run_through_shell(f"{self.earbuds_binary} set anc true") + + def touchpad_set(self, setting): + run_through_shell(f"{self.earbuds_binary} set touchpad {setting}") + + @staticmethod + def translate_placement(placement): + mapping = { + BudsPlacementStatus.wearing.value: "W", + BudsPlacementStatus.idle.value: "I", + BudsPlacementStatus.case.value: "C", + } + return mapping.get(placement, "?") diff --git a/tests/test_buds.json b/tests/test_buds.json new file mode 100644 index 00000000..3f1010d4 --- /dev/null +++ b/tests/test_buds.json @@ -0,0 +1,48 @@ +{ + "connected_payload": { + "status": "success", + "device": "00:00:00:00:00:00", + "status_message": null, + "payload": { + "address": "00:00:00:00:00:00", + "ready": true, + "batt_left": 53, + "batt_right": 48, + "batt_case": 88, + "placement_left": 1, + "placement_right": 1, + "equalizer_type": 0, + "touchpads_blocked": false, + "noise_reduction": false, + "did_battery_notify": false, + "touchpad_option_left": 2, + "touchpad_option_right": 2, + "paused_music_earlier": false, + "debug": { + "voltage_left": 0.0, + "voltage_right": 0.0, + "temperature_left": 36.0, + "temperature_right": 37.0, + "current_left": 0.0, + "current_right": 0.0 + }, + "model": "Buds2", + "ambient_sound_enabled": false, + "ambient_sound_volume": 0, + "extra_high_ambient_volume": false, + "tab_lock_status": { + "touch_an_hold_on": true, + "triple_tap_on": true, + "double_tap_on": true, + "tap_on": true, + "touch_controls_on": true + } + } + }, + "disconnected_payload": { + "status": "error", + "device": "", + "status_message": "No connected device found", + "payload": null + } +} diff --git a/tests/test_buds.py b/tests/test_buds.py new file mode 100644 index 00000000..2c9da844 --- /dev/null +++ b/tests/test_buds.py @@ -0,0 +1,542 @@ +import json +import unittest +from copy import deepcopy +from unittest.mock import patch +from i3pystatus.core.color import ColorRangeModule +from i3pystatus.buds import Buds, BudsEqualizer, BudsPlacementStatus + + +class TestBuds(unittest.TestCase): + def setUp(self): + self.buds = Buds() + with open('test_buds.json', 'rb') as file: + self.payload = json.load(file) + + @patch('i3pystatus.buds.run_through_shell') + def test_run_device_connected(self, mock_run): + # Setup: Use json.dumps as we expect JSON + payload = self.payload.get('connected_payload') + mock_run.return_value.out = json.dumps(payload) + + # Action: Call run() and save return for comparison + buds_run_return = self.buds.run() + + # Verify: Assert called with right params + mock_run.assert_called_with(f"{self.buds.earbuds_binary} status -o json -q") + + expected_output = { + "full_text": "Buds2 LW53 48RW", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + # Verify: Assert correct output + self.assertEqual(expected_output, self.buds.output) + # Verify: run() return is equal to payload + self.assertDictEqual(payload.get('payload'), buds_run_return) + + @patch('i3pystatus.buds.run_through_shell') + def test_run_device_disconnected(self, mock_run): + # Setup: Use json.dumps as we expect JSON + mock_run.return_value.out = json.dumps(self.payload.get('disconnected_payload')) + + # Action: Call run() and save return for comparison + buds_run_return = self.buds.run() + + # Verify: Assert called with right params + mock_run.assert_called_with(f"{self.buds.earbuds_binary} status -o json -q") + + expected_output = { + "full_text": "Disconnected", + "color": self.buds.disconnected_color + } + + # Verify: Assert correct output + self.assertEqual(expected_output, self.buds.output) + # Verify: run() return should be none + self.assertIsNone(buds_run_return) + + @patch('i3pystatus.buds.run_through_shell') + def test_toggle_amb(self, mock_run): + # Setup: AMB is initially disabled + modified_payload = deepcopy(self.payload.get('connected_payload')) + modified_payload['payload']['ambient_sound_enabled'] = False + + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Toggle AMB + self.buds.toggle_amb() + + # Verify: The correct command is sent to enable AMB + mock_run.assert_called_with(f"{self.buds.earbuds_binary} set ambientsound 1") + + # Setup: Change the payload again to update the AMB status + modified_payload['payload']['ambient_sound_enabled'] = True + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run again to update output + self.buds.run() + + # Verify: The output correctly displays AMB is enabled + expected_output = { + "full_text": "Buds2 LW53 48RW AMB", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + self.assertEqual(expected_output, self.buds.output) + + # Action: Toggle AMB again + self.buds.toggle_amb() + + # Verify: The correct command is sent to disable AMB this time + mock_run.assert_called_with(f"{self.buds.earbuds_binary} set ambientsound 0") + + # Setup: Change the payload one last time to update the AMB status + modified_payload['payload']['ambient_sound_enabled'] = False + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run again to update output + self.buds.run() + + # Verify: The output correctly displays AMB is disabled + expected_output = { + "full_text": "Buds2 LW53 48RW", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + self.assertEqual(expected_output, self.buds.output) + + @patch('i3pystatus.buds.run_through_shell') + def test_toggle_anc(self, mock_run): + # Setup: ANC is initially disabled + modified_payload = deepcopy(self.payload.get('connected_payload')) + modified_payload['payload']['noise_reduction'] = False + + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Toggle ANC + self.buds.toggle_anc() + + # Verify: The correct command is sent to enable ANC + mock_run.assert_called_with(f"{self.buds.earbuds_binary} set anc true") + + # Setup: Change the payload again to update the ANC status + modified_payload['payload']['noise_reduction'] = True + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run again to update output + self.buds.run() + + # Verify: The output correctly displays ANC is enabled + expected_output = { + "full_text": "Buds2 LW53 48RW ANC", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + self.assertEqual(expected_output, self.buds.output) + + # Action: Toggle ANC again + self.buds.toggle_anc() + + # Verify: The correct command is sent to disable ANC this time + mock_run.assert_called_with(f"{self.buds.earbuds_binary} set anc false") + + # Setup: Change the payload one last time to update the ANC status + modified_payload['payload']['noise_reduction'] = False + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run again to update output + self.buds.run() + + # Verify: The output correctly displays ANC is disabled + expected_output = { + "full_text": "Buds2 LW53 48RW", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + self.assertEqual(expected_output, self.buds.output) + + @patch('i3pystatus.buds.run_through_shell') + def test_combined_battery(self, mock_run): + # Setup: Equal left and right battery value + modified_payload = deepcopy(self.payload.get('connected_payload')) + modified_payload['payload']['batt_left'] = modified_payload['payload']['batt_right'] + + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run() to update the output + self.buds.run() + + expected_output = { + "full_text": "Buds2 LW48RW", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + # Verify: The output correctly displays combined battery status + self.assertEqual(expected_output, self.buds.output) + + # Setup: Different left and right battery value + mock_run.return_value.out = json.dumps(self.payload.get('connected_payload')) + + # Action: Call run() again to update the output + self.buds.run() + + expected_output = { + "full_text": "Buds2 LW53 48RW", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + # Verify: The output correctly displays combined battery status + self.assertEqual(expected_output, self.buds.output) + + @patch('i3pystatus.buds.run_through_shell') + def test_combined_battery_drift(self, mock_run): + # Setup: Different battery level, should show smaller + modified_payload = deepcopy(self.payload.get('connected_payload')) + modified_payload['payload']['batt_left'] = modified_payload['payload']['batt_right'] + modified_payload['payload']['batt_left'] -= self.buds.battery_drift_threshold + + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run() to update the output + self.buds.run() + + expected_level = min(modified_payload['payload']['batt_left'], modified_payload['payload']['batt_right']) + expected_output = { + # Verify: The level should be the smallest one + "full_text": + f"Buds2 LW{expected_level}RW", + "color": self.buds.get_gradient( + expected_level, + self.buds.colors, + self.buds.battery_limit + ) + } + + # Verify: The output correctly displays combined battery status + self.assertEqual(expected_output, self.buds.output) + + # Setup: One battery is at level 0, should show the other + modified_payload = deepcopy(self.payload.get('connected_payload')) + modified_payload['payload']['batt_left'] = 0 + modified_payload['payload']['batt_right'] = 0 + self.buds.battery_drift_threshold + + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run() again to update the output + self.buds.run() + + expected_level = max(modified_payload['payload']['batt_left'], modified_payload['payload']['batt_right']) + expected_output = { + # Verify: The level should be the biggest one + "full_text": + f"Buds2 LW{expected_level}RW", + "color": self.buds.get_gradient( + expected_level, + self.buds.colors, + self.buds.battery_limit + ) + } + + # Verify: The output correctly displays combined battery status + self.assertEqual(expected_output, self.buds.output) + + @patch('i3pystatus.buds.run_through_shell') + def test_combined_battery_drift_case(self, mock_run): + # Setup: Change status of one buds to be on the case + modified_payload = deepcopy(self.payload.get('connected_payload')) + modified_payload['payload']['placement_left'] = BudsPlacementStatus.case + + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run() to update the output + self.buds.run() + + expected_output = { + "full_text": f"Buds2 LC53 48RW 88C", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + # Verify: The output correctly displays combined battery status + self.assertEqual(expected_output, self.buds.output) + + @patch('i3pystatus.buds.run_through_shell') + def test_connect(self, mock_run): + # Action: Call connect + self.buds.connect() + + # Verify: The correct command is sent to connect + mock_run.assert_called_with(f"{self.buds.earbuds_binary} connect") + + @patch('i3pystatus.buds.run_through_shell') + def test_disconnect(self, mock_run): + # Action: Call disconnect + self.buds.disconnect() + + # Verify: The correct command is sent to disconnect + mock_run.assert_called_with(f"{self.buds.earbuds_binary} disconnect") + + @patch('i3pystatus.buds.run_through_shell') + def test_restart_daemin(self, mock_run): + # Action: Call restart_daemon + self.buds.restart_daemon() + + # Verify: The correct command is sent to restart the daemon + mock_run.assert_called_with(f"{self.buds.earbuds_binary} -kd") + + def run_placement_helper(self, mock_run, placement_left, placement_right, case_battery, expected_display): + modified_payload = deepcopy(self.payload.get('connected_payload')) + modified_payload['payload']['placement_left'] = placement_left + modified_payload['payload']['placement_right'] = placement_right + if case_battery is not None: + modified_payload['payload']['batt_case'] = case_battery + mock_run.return_value.out = json.dumps(modified_payload) + + self.buds.run() + + expected_output = { + "full_text": expected_display, + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + self.assertEqual(expected_output, self.buds.output) + + @patch('i3pystatus.buds.run_through_shell') + def test_placement_wearing(self, mock_run): + self.run_placement_helper( + mock_run, + BudsPlacementStatus.wearing.value, + BudsPlacementStatus.wearing.value, + None, + "Buds2 LW53 48RW" + ) + + @patch('i3pystatus.buds.run_through_shell') + def test_placement_idle(self, mock_run): + self.run_placement_helper( + mock_run, + BudsPlacementStatus.idle.value, + BudsPlacementStatus.idle.value, + None, + "Buds2 LI53 48RI" + ) + + @patch('i3pystatus.buds.run_through_shell') + def test_placement_case_with_battery(self, mock_run): + # Verify: Case battery is returned if a bud is on the case + self.run_placement_helper( + mock_run, + BudsPlacementStatus.case.value, + BudsPlacementStatus.case.value, + 88, + "Buds2 LC53 48RC 88C" + ) + + @patch('i3pystatus.buds.run_through_shell') + def test_battery_level_dynamic_color(self, mock_run): + # Setup: Build the colors array independently of our class + colors = ColorRangeModule.get_hex_color_range( + self.buds.end_color, + self.buds.start_color, + self.buds.battery_limit + ) + modified_payload = deepcopy(self.payload.get('connected_payload')) + + for battery_level in range(0, self.buds.battery_limit + 1): + # Setup: Make both levels equal + modified_payload['payload']['batt_left'] = battery_level + modified_payload['payload']['batt_right'] = battery_level + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run() again to update the output + self.buds.run() + + expected_output = { + "full_text": f"Buds2 LW{battery_level}RW", + "color": self.buds.get_gradient( + battery_level, + colors, + self.buds.battery_limit + ) + } + + self.assertEqual(expected_output, self.buds.output) + + @patch('i3pystatus.buds.run_through_shell') + def test_set_equalizer_direct(self, mock_run): + for eq_setting in BudsEqualizer: + with self.subTest(msg=f"Failed testing equalizer {eq_setting.name}", eq_setting=eq_setting): + # Setup: Create a copy of the payload + modified_payload = deepcopy(self.payload.get('connected_payload')) + + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call the set function with each equalizer setting + self.buds.equalizer_set(eq_setting) + + expected_command = f"{self.buds.earbuds_binary} set equalizer {eq_setting.name}" + + # Verify: Correct equalizer command is used + mock_run.assert_called_with(expected_command) + + # Setup: Modify payload to verify output + modified_payload['payload']['equalizer_type'] = eq_setting.value + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run() again to update the output + self.buds.run() + + expected_equalizer = f" {eq_setting.name.capitalize()}" if eq_setting.name != "off" else "" + expected_output = { + "full_text": f"Buds2 LW53 48RW{expected_equalizer}", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + # Verify: Output was updated with equalizer + self.assertEqual(expected_output, self.buds.output) + + @patch('i3pystatus.buds.run_through_shell') + def test_increment_equalizer(self, mock_run): + # Setup: Create a copy of the payload + modified_payload = deepcopy(self.payload.get('connected_payload')) + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call the set to increment by one the equalizer setting + self.buds.equalizer_set(+1) + + # Verify: Correct equalizer command is used + expected_command = f"{self.buds.earbuds_binary} set equalizer {BudsEqualizer.bass.name}" + mock_run.assert_called_with(expected_command) + + # Setup: Modify payload to verify output + modified_payload['payload']['equalizer_type'] = BudsEqualizer.bass.value + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run() again to update the output + self.buds.run() + + expected_equalizer = f" {BudsEqualizer.bass.name.capitalize()}" + expected_output = { + "full_text": f"Buds2 LW53 48RW{expected_equalizer}", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + # Verify: Output was updated with equalizer + self.assertEqual(expected_output, self.buds.output) + + @patch('i3pystatus.buds.run_through_shell') + def test_decrement_equalizer_from_off(self, mock_run): + # Setup: Create a copy of the payload + modified_payload = deepcopy(self.payload.get('connected_payload')) + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call the set to decrement by one the equalizer setting + self.buds.equalizer_set(-1) + + # Verify: Correct equalizer command is used + expected_command = f"{self.buds.earbuds_binary} set equalizer {BudsEqualizer.treble.name}" + mock_run.assert_called_with(expected_command) + + # Setup: Modify payload to verify output + modified_payload['payload']['equalizer_type'] = BudsEqualizer.treble.value + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run() again to update the output + self.buds.run() + + expected_equalizer = f" {BudsEqualizer.treble.name.capitalize()}" + expected_output = { + "full_text": f"Buds2 LW53 48RW{expected_equalizer}", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + # Verify: Output was updated with equalizer + self.assertEqual(expected_output, self.buds.output) + + def run_touchpad_set(self, mock_run, setting_value): + # Setup: Create a copy of the payload + modified_payload = deepcopy(self.payload.get('connected_payload')) + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call the set with the appropriate setting + self.buds.touchpad_set(f'{setting_value}') + + # Verify: Correct command to disable the touchpad is called + expected_command = f"{self.buds.earbuds_binary} set touchpad {setting_value}" + mock_run.assert_called_with(expected_command) + + # Setup: Modify the payload if we are disabling the touchpad + if setting_value == 'false': + modified_payload['payload']['tab_lock_status']['touch_an_hold_on'] = False + modified_payload['payload']['tab_lock_status']['tap_on'] = False + mock_run.return_value.out = json.dumps(modified_payload) + + # Action: Call run() again to update the output + self.buds.run() + + # Setup: + expected_output = { + "full_text": f"Buds2 LW53 48RW{' TL' if setting_value == 'false' else ''}", + "color": self.buds.get_gradient( + 48, + self.buds.colors, + self.buds.battery_limit + ) + } + + # Verify: Output was updated with equalizer + self.assertEqual(expected_output, self.buds.output) + + @patch('i3pystatus.buds.run_through_shell') + def test_touchpad_disable(self, mock_run): + self.run_touchpad_set(mock_run, "false") + + @patch('i3pystatus.buds.run_through_shell') + def test_touchpad_enable(self, mock_run): + self.run_touchpad_set(mock_run, "true")