diff --git a/.github/workflows/build-pyinstall.yml b/.github/workflows/build-pyinstall.yml new file mode 100644 index 0000000..560472b --- /dev/null +++ b/.github/workflows/build-pyinstall.yml @@ -0,0 +1,69 @@ +name: Build PyInstall +# Based on https://github.com/sayyid5416/pyinstaller + +on: + push: + pull_request: + schedule: + # * is a special character in YAML so you have to quote this string + # Run at 06:21 UTC on the 8th of every month (odd time to reduce load) + - cron: '21 06 8 * *' + workflow_dispatch: + # To limit to "main" branch, add to above... + # branches: [ "main" ] + +# Get git tag info via GitHub API due to shallow clone: +# See... +# https://github.com/marketplace/actions/gh-describe +# Alongside these resources, not used here: +# https://stackoverflow.com/questions/66349002/get-latest-tag-git-describe-tags-when-repo-is-cloned-with-depth-1 +# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7 + +jobs: + pyinstall-windows: + runs-on: windows-latest + steps: + - name: Git describe + id: ghd + uses: proudust/gh-describe@v2 + with: + default: "notags-${{ github.sha }}" + - name: Check outputs + run: | + echo "describe : ${{ steps.ghd.outputs.describe }}" + echo "tag : ${{ steps.ghd.outputs.tag }}" + echo "distance : ${{ steps.ghd.outputs.distance }}" + echo "sha : ${{ steps.ghd.outputs.sha }}" + echo "short-sha : ${{ steps.ghd.outputs.short-sha }}" + - name: Build executable + uses: sayyid5416/pyinstaller@v1 + with: + spec: 'hapticpancake.spec' + requirements: 'BridgeApp/requirements.txt' + upload_exe_with_name: 'hapticpancake_windows_${{ steps.ghd.outputs.describe }}' + # options: --onefile, --windowed, --collect-all openvr, --name "hapticpancake", --icon=Images\icon.ico + # These options are not used when passing in a .spec file + + pyinstall-linux: + runs-on: ubuntu-latest + steps: + - name: Git describe + id: ghd + uses: proudust/gh-describe@v2 + with: + default: "notags-${{ github.sha }}" + - name: Check outputs + run: | + echo "describe : ${{ steps.ghd.outputs.describe }}" + echo "tag : ${{ steps.ghd.outputs.tag }}" + echo "distance : ${{ steps.ghd.outputs.distance }}" + echo "sha : ${{ steps.ghd.outputs.sha }}" + echo "short-sha : ${{ steps.ghd.outputs.short-sha }}" + - name: Build executable + uses: sayyid5416/pyinstaller@v1 + with: + spec: 'hapticpancake.spec' + requirements: 'BridgeApp/requirements.txt' + upload_exe_with_name: 'hapticpancake_linux_${{ steps.ghd.outputs.describe }}' + # options: --onefile, --windowed, --collect-all openvr, --name "hapticpancake", --icon=Images\icon.ico + # These options are not used when passing in a .spec file diff --git a/BridgeApp/app_config.py b/BridgeApp/app_config.py index 47337db..a1c445e 100644 --- a/BridgeApp/app_config.py +++ b/BridgeApp/app_config.py @@ -21,12 +21,11 @@ def __init__(self, index: int, model: str, serial: str): @staticmethod def get_multiplier(model: str): - if model.startswith("Tundra"): - return 100.0 if model.startswith("VIVE Controller"): return 100.0 - else: - return 1.0 + + # Vive Tracker, Tundra Tracker, etc + return 1.0 # This is a definition class for storing user settings per tracker diff --git a/BridgeApp/app_gui.py b/BridgeApp/app_gui.py index 542b633..999fb0b 100644 --- a/BridgeApp/app_gui.py +++ b/BridgeApp/app_gui.py @@ -1,4 +1,4 @@ -import PySimpleGUI as sg +import FreeSimpleGUI as sg import webbrowser from app_config import AppConfig, PatternConfig @@ -43,6 +43,7 @@ def __init__(self, app_config: AppConfig, tracker_test_event, self.add_external_event = add_external_event self.config = app_config + self.shutting_down = False self.window = None self.trackers = [] self.osc_status_bar = sg.Text('', key=KEY_OSC_STATUS_BAR) @@ -89,10 +90,10 @@ def build_pattern_setting_layout(key: str, pattern_list: [str], pattern_config: k=key + KEY_VIB_PATTERN, size=15, readonly=True, enable_events=True)], [sg.Text("Strength:"), sg.Text("Min:", pad=0), - sg.Spin([num for num in range(0, 100)], pattern_config.str_min, pad=0, + sg.Spin([num for num in range(0, 101)], pattern_config.str_min, pad=0, key=key + KEY_VIB_STR_MIN, enable_events=True), sg.Text("Max:", pad=0), - sg.Spin([num for num in range(0, 100)], pattern_config.str_max, pad=0, + sg.Spin([num for num in range(0, 101)], pattern_config.str_max, pad=0, key=key + KEY_VIB_STR_MAX, enable_events=True)], [sg.Text("Speed:", size=6, tooltip=speed_tooltip), sg.Slider(range=(1, 32), size=(13, 10), default_value=pattern_config.speed, tooltip=speed_tooltip, @@ -111,6 +112,10 @@ def device_row(self, tracker_serial, tracker_model, additional_layout, icon=None dev_config = self.config.get_tracker_config(tracker_serial) address = dev_config.address + vib_multiplier = dev_config.multiplier_override + battery_threshold = dev_config.battery_threshold + + multiplier_tooltip = "Additional strength multiplier\nCompensates for different trackers\n1.0 for default (Vive/Tundra Tracker)\n200 for Vive Wand\n400 for Index c." print(f"[GUI] Adding tracker: {string}") layout = [ @@ -180,13 +185,14 @@ def add_external_device(self, device_serial, device_model): def add_target(self, tracker_serial, tracker_model, layout): if tracker_serial in self.trackers: - print(f"[GUI] Device {tracker_serial} is already on the list. Skipping...") + print(f"[GUI] Tracker {tracker_serial} is already on the list. Skipping...") return + row = [self.tracker_row(tracker_id, tracker_serial, tracker_model)] if self.window is not None: - self.window.extend_layout(self.window[KEY_LAYOUT_TRACKERS], layout) + self.window.extend_layout(self.window[KEY_LAYOUT_TRACKERS], row) else: - self.tracker_frame.layout(layout) + self.tracker_frame.layout(row) self.trackers.append(tracker_serial) @@ -211,10 +217,11 @@ def update_osc_status_bar(self, message, is_error=False): self.osc_status_bar.DisplayText = message self.osc_status_bar.TextColor = text_color return - try: - self.osc_status_bar.update(message, text_color=text_color) - except Exception as e: - print("[GUI] Failed to update server status bar.") + if not self.shutting_down: + try: + self.osc_status_bar.update(message, text_color=text_color) + except Exception as e: + print("[GUI] Failed to update server status bar.") def run(self): if self.window is None: @@ -227,6 +234,7 @@ def run(self): # React to Event if event == sg.WIN_CLOSED or event == 'Exit': # if user closes window or clicks cancel + self.shutting_down = True print("[GUI] Closing application.") return False if event[0] == KEY_BTN_TEST: diff --git a/BridgeApp/app_runner.py b/BridgeApp/app_runner.py index ff3925e..26b3c74 100644 --- a/BridgeApp/app_runner.py +++ b/BridgeApp/app_runner.py @@ -22,7 +22,56 @@ def __init__(self, config: AppConfig, tracker: VRTracker, pulse_function, batter self.strength_delta: float = 0.0 self.last_str_set_time = time.time() + # Some devices (e.g. Tundra Trackers) use microseconds instead of + # milliseconds for the legacy triggerHapticPulse() function, but then + # limit you to around 3999µs per pulse. + # + # TODO: That number is probably not exact; check with an oscilloscope. + # + # See https://steamcommunity.com/app/358720/discussions/0/405693392914144440/ + # + # In these situations, the update loop needs to run much faster to + # try to avoid exceeding the maximum duration of one haptic pulse. + # Exceeding 100% strength requires extending a pulse into the next loop + # iteration. Any manual pulses also need to be spread out over time to + # not exceed the limit. + # + # TODO: Try switching from "triggerHapticPulse()" to IVRInput + # triggerHapticPulse() is deprecated as per Valve: + # * https://github.com/ValveSoftware/openvr/blob/v2.2.3/headers/openvr.h#L2431-L2433 + # * https://github.com/ValveSoftware/openvr/blob/v2.2.3/headers/openvr.h#L5216-L5218 + # + # This might be specific to Tundra Trackers vs. Vive Trackers, as + # Tundra ships their IO Expansion board with a LRA/haptic actuator + + # Assume trackers that behave as expected self.interval_ms = 50 # millis + + # HACK: multiplier to convert from milliseconds to the device time unit + self.hack_pulse_mult_to_ms = 0 + # HACK: maximum allowed pulse duration in milliseconds + # Exceeding the limit likely results in sporadic or glitchy vibration + # instead of a max strength (as continuous as possible) pulse. + self.hack_pulse_limit_ms = 0 + # HACK: if true, the warning for a pulse exceeding the pulse limit has + # been shown. Only shown once to avoid console spam. + self.hack_pulse_limit_exceeded = False + # HACK: when to stop a queued force pulse + self.hack_pulse_force_stop_time = 0 + + # Special-case as needed + if self.tracker.model.startswith("Tundra"): + # Tundra Tracker + # Works in microseconds + self.hack_pulse_mult_to_ms = 1 / 1000 + # Has a limit of roughly 4000 microseconds + self.hack_pulse_limit_ms = 4000 * self.hack_pulse_mult_to_ms + + if self.hack_pulse_limit_ms > 0: + # Apply pulse duration hack/workaround + self.interval_ms = self.hack_pulse_limit_ms + print(f"[VibrationManager] Using {self.interval_ms} ms pulse limit workaround for {self.tracker.serial}") + self.interval_s = self.interval_ms / 1000 # seconds self.vp = VibrationPattern(self.config) @@ -43,10 +92,51 @@ def run(self): while True: start_time = time.time() + pulse_length = 0 + strength = self.calculate_strength(start_time) - # So we pulse every 50 ms that means a 50 ms pulse would be 100% if strength > 0: - self.pulse_function(self.tracker.index, int(strength * self.interval_ms)) + # So we pulse every self.interval_ms (e.g. 50) ms. That means a + # self.interval_ms (50/etc) ms pulse would be 100%. + pulse_length = strength * self.interval_ms + # Don't cap strength to 1.0 as velocity calculation can easily + # exceed 1. Also, some may rely on overriding the multiplier + # to adjust for different timescales, etc. + + # Check if there's a queued force pulse + if start_time < self.hack_pulse_force_stop_time: + # Convert to milliseconds + # (Don't cap this so if a pulse limit is specified, the + # leftover pulse carries over.) + force_pulse_duration = (self.hack_pulse_force_stop_time - start_time) * 1000 + # Pick the biggest number for pulse_length + pulse_length = max(pulse_length, force_pulse_duration) + + # If a maximum pulse length is specified... + if self.hack_pulse_limit_ms > 0: + # ...and we exceed it... + if pulse_length > self.hack_pulse_limit_ms: + if not self.hack_pulse_limit_exceeded: + print(f"[VibrationManager] {round(pulse_length, 2)} ms pulse exceeds {self.interval_ms} ms limit for {self.tracker.serial}, extending next pulse [this warning won't repeat]") + self.hack_pulse_limit_exceeded = True + + # Carry over any excess to the next loop iteration by + # queuing it as a forced pulse. Don't subtract anything as + # this pulse's time.sleep() also reduces the next pulse. + self.force_pulse(pulse_length) + # Do a max length pulse now + pulse_length = self.hack_pulse_limit_ms + + # Convert to target unit of time if necessary + if self.hack_pulse_mult_to_ms: + pulse_length = pulse_length / self.hack_pulse_mult_to_ms + + # Convert to integer (after all else for max precision) + pulse_length = int(pulse_length) + + # Trigger pulse if nonzero length requested + if pulse_length > 0: + self.pulse_function(self.tracker.index, pulse_length) sleep = max(self.interval_s - (time.time() - start_time), 0.0) time.sleep(sleep) @@ -77,4 +167,15 @@ def apply_multiplier(self, strength): * self.config.get_tracker_config(self.tracker.serial).multiplier_override) def force_pulse(self, length): - self.pulse_function(self.tracker.index, int(length * self.tracker.pulse_multiplier)) + if self.hack_pulse_limit_ms > 0: + # Add the pulse length in milliseconds to the current time in + # seconds, determining the new target time to stop + self.hack_pulse_force_stop_time = time.time() + (length / 1000) + # NOTE: This is also used by run() to handle lengths that exceed + # the maximum. + else: + # Convert to target unit of time if necessary + if self.hack_pulse_mult_to_ms: + length = length / self.hack_pulse_mult_to_ms + # Trigger haptic pulse + self.pulse_function(self.tracker.index, int(length * self.tracker.pulse_multiplier)) diff --git a/BridgeApp/requirements.txt b/BridgeApp/requirements.txt index 76173c0..4bdff63 100644 --- a/BridgeApp/requirements.txt +++ b/BridgeApp/requirements.txt @@ -1,6 +1,6 @@ -pysimplegui==4.60.5 -openvr==1.26.701 -pydantic==2.5.3 -pyserial==3.5 -python-osc==1.8.3 -websockets==12.0 \ No newline at end of file +freesimplegui~=5.1.0 +openvr~=1.26.701 +pydantic~=2.8.0 +pyserial~=3.5 +python-osc~=1.8.3 +websockets~=12.0 diff --git a/BridgeApp/target_ovr.py b/BridgeApp/target_ovr.py index b426d3b..22b7692 100644 --- a/BridgeApp/target_ovr.py +++ b/BridgeApp/target_ovr.py @@ -51,13 +51,19 @@ def get_serial(self, index): def get_model(self, index): try: - result = self.vr.getStringTrackedDeviceProperty(index, openvr.Prop_ModelNumber_String) + return self.vr.getStringTrackedDeviceProperty(index, openvr.Prop_ModelNumber_String) except openvr.error_code.TrackedProp_UnknownProperty: - result = "Unknown Tracker" - return result + # Some devices (e.g. Vive Tracker 1.0) don't report a model number. + return "Unknown Tracker" def get_battery_level(self, index): - return self.vr.getFloatTrackedDeviceProperty(index, openvr.Prop_DeviceBatteryPercentage_Float) + try: + return self.vr.getFloatTrackedDeviceProperty(index, openvr.Prop_DeviceBatteryPercentage_Float) + except openvr.error_code.TrackedProp_UnknownProperty: + # Some devices (e.g. Tundra Trackers) may be delayed in reporting a + # battery percentage, especially if fully charged. If missing, + # assume 100% battery. + return 1 def set_strength(self, serial, strength): if serial in self.vibration_managers: diff --git a/hapticpancake.spec b/hapticpancake.spec index a66bdbf..3cd6e8f 100644 --- a/hapticpancake.spec +++ b/hapticpancake.spec @@ -9,7 +9,7 @@ datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] a = Analysis( - ['BridgeApp\\main.py'], + ['BridgeApp/main.py'], pathex=[], binaries=binaries, datas=datas, @@ -41,5 +41,5 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, - icon=['Images\\icon.ico'], + icon=['Images/icon.ico'], )