Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into dev/v0.6.0
Browse files Browse the repository at this point in the history
# Conflicts:
#	BridgeApp/app_gui.py
  • Loading branch information
Z4urce committed Nov 10, 2024
2 parents 55ff036 + 011bff2 commit 4c5a700
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 29 deletions.
69 changes: 69 additions & 0 deletions .github/workflows/build-pyinstall.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 3 additions & 4 deletions BridgeApp/app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 18 additions & 10 deletions BridgeApp/app_gui.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import PySimpleGUI as sg
import FreeSimpleGUI as sg
import webbrowser

from app_config import AppConfig, PatternConfig
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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 = [
Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand All @@ -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:
Expand Down
107 changes: 104 additions & 3 deletions BridgeApp/app_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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))
12 changes: 6 additions & 6 deletions BridgeApp/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
freesimplegui~=5.1.0
openvr~=1.26.701
pydantic~=2.8.0
pyserial~=3.5
python-osc~=1.8.3
websockets~=12.0
14 changes: 10 additions & 4 deletions BridgeApp/target_ovr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions hapticpancake.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,5 +41,5 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['Images\\icon.ico'],
icon=['Images/icon.ico'],
)

0 comments on commit 4c5a700

Please sign in to comment.