Skip to content

Instantly share code, notes, and snippets.

@jxsl13
Created August 26, 2026 12:44
Show Gist options
  • Select an option

  • Save jxsl13/78e5fb191453d5634222b2d1a7a39773 to your computer and use it in GitHub Desktop.

Select an option

Save jxsl13/78e5fb191453d5634222b2d1a7a39773 to your computer and use it in GitHub Desktop.
g-hub.lua
-- =====================================================================
-- Logitech G HUB - Human Circle Mouse Jiggler
-- =====================================================================
--
-- QUICK START
--
-- Normally you only need to configure:
--
-- TOGGLE_BUTTON
-- PERSON_SEED
--
-- For the LED indicator, create the two G HUB macros described below.
--
--
-- =====================================================================
-- PRO 2 LIGHTSPEED - PROGRAMMABLE BUTTONS
-- =====================================================================
--
-- Logitech specifies 8 programmable physical buttons on the
-- PRO 2 LIGHTSPEED:
--
-- Physical button Device designation
-- ---------------------------------------------------------------
-- Left mouse button Button 1
-- Right mouse button Button 2
-- Wheel / middle click Button 3
-- Side Back G4
-- Side Forward G5
-- Opposite-side Back G6
-- Opposite-side Forward G7
-- Bottom DPI cycle button Button 8
--
-- The magnetic side buttons allow right-handed, left-handed,
-- no-side-button, and full-side-button configurations.
--
--
-- =====================================================================
-- VALID TOGGLE_BUTTON VALUES FOR THIS SCRIPT
-- =====================================================================
--
-- IMPORTANT:
--
-- Logitech's physical button numbering and the Lua mouse-button
-- numbering are NOT identical for the right and middle buttons.
--
-- Use these LUA values for TOGGLE_BUTTON:
--
-- TOGGLE_BUTTON = 1
-- Left mouse button
-- Physical PRO 2 button: Button 1
-- Lua: Left Mouse Button
--
-- Supported by this script.
-- G HUB does not report the primary button by default, therefore
-- the script automatically enables primary-button events when
-- TOGGLE_BUTTON is set to 1.
--
--
-- TOGGLE_BUTTON = 2
-- Wheel / middle click
-- Physical PRO 2 button: Button 3
-- Lua: Middle Mouse Button
--
-- Supported by this script.
--
--
-- TOGGLE_BUTTON = 3
-- Right mouse button
-- Physical PRO 2 button: Button 2
-- Lua: Right Mouse Button
--
-- Supported by this script.
--
--
-- TOGGLE_BUTTON = 4
-- XButton1 / Back
-- Normally corresponds to G4 in the standard right-handed setup.
--
-- Supported by this script.
-- RECOMMENDED.
--
--
-- TOGGLE_BUTTON = 5
-- XButton2 / Forward
-- Normally corresponds to G5 in the standard right-handed setup.
--
-- Supported by this script.
-- RECOMMENDED.
--
--
-- ---------------------------------------------------------------------
-- WHY G6, G7 AND BUTTON 8 ARE NOT VALID TOGGLE_BUTTON VALUES HERE
-- ---------------------------------------------------------------------
--
-- G6, G7 and the bottom Button 8 are programmable in G HUB.
--
-- G HUB can generate mouse-button events for additional physical
-- buttons on supported mice, but Logitech's IsMouseButtonPressed()
-- polling API only defines the following buttons:
--
-- 1 = Left
-- 2 = Middle
-- 3 = Right
-- 4 = X1
-- 5 = X2
--
-- This script must poll the toggle button WHILE:
--
-- - drawing a circle
-- - sleeping for up to 180 seconds
-- - waiting inside the active Lua event handler
--
-- Therefore this implementation intentionally restricts TOGGLE_BUTTON
-- to Lua mouse buttons 1 through 5.
--
-- Using G6, G7 or Button 8 would make immediate interruption of the
-- active loop unreliable because IsMouseButtonPressed() cannot poll
-- those buttons.
--
-- For the simplest and least intrusive configuration use:
--
-- 4 = G4 / Back
-- 5 = G5 / Forward
--
-- =====================================================================
-- =====================================================================
-- USER CONFIGURATION
-- =====================================================================
local TOGGLE_BUTTON = 5
-- Persistent virtual person.
--
-- Change this number to create a different person.
-- Keep it unchanged to always use the same person.
--
-- You may enter ANY integer.
--
-- Native internal PRNG state range:
--
-- 1 .. 2147483646
--
-- You do NOT need to manually stay inside this range.
-- The script normalizes the configured value automatically.
--
-- Examples:
--
-- 731942
-- 42
-- 123456789
-- 0
-- -12345
-- 9999999999
--
-- For consistent behavior, use a whole number and keep it unchanged.
--
local PERSON_SEED = 731942
-- =====================================================================
-- DPI LED INDICATOR - ONE-TIME G HUB SETUP
-- =====================================================================
--
-- G HUB Lua cannot directly set the current DPI table index.
--
-- The script therefore uses two ordinary G HUB macros.
--
-- Create TWO "No Repeat" macros:
--
-- Macro name: DPI_UP
-- Action: System -> Mouse -> DPI Up
--
-- Macro name: DPI_DOWN
-- Action: System -> Mouse -> DPI Down
--
--
-- Keep at least three DPI stages configured.
--
-- The script uses:
--
-- DPI/Profile 1 -> one LED
-- DPI/Profile 3 -> three LEDs
--
--
-- ENABLE INDICATION:
--
-- one LED
-- ->
-- three LEDs
-- ->
-- one LED
-- ->
-- three LEDs
-- ->
-- one LED
-- ->
-- three LEDs
-- ->
-- one LED
--
--
-- DISABLE INDICATION:
--
-- one LED
--
--
-- DPI/Profile 1 should therefore contain your normal working DPI.
--
-- Example:
--
-- DPI 1 = 800 DPI <- normal working DPI
-- DPI 2 = 850 DPI
-- DPI 3 = 900 DPI
--
-- The temporary higher DPI stages are selected only while displaying
-- the indicator. Automated cursor movement starts only after DPI 1 has
-- been restored.
--
-- =====================================================================
local DPI_UP_MACRO = "DPI_UP"
local DPI_DOWN_MACRO = "DPI_DOWN"
local DPI_MAX_PROFILE_COUNT = 5
local DPI_STEPS_TO_THREE_LEDS = 2
local DPI_COMMAND_DELAY_MS = 65
local LED_ON_PULSE_COUNT = 3
local LED_ON_HOLD_MIN_MS = 140
local LED_ON_HOLD_MAX_MS = 210
local LED_ON_GAP_MIN_MS = 100
local LED_ON_GAP_MAX_MS = 160
local LED_OFF_HOLD_MIN_MS = 180
local LED_OFF_HOLD_MAX_MS = 260
-- =====================================================================
-- PAUSE BETWEEN CIRCLES
-- =====================================================================
--
-- A completely new pause is calculated after EVERY circle.
--
-- Center-weighted randomization is used:
--
-- minimum = 10 seconds
-- maximum = 180 seconds
--
-- Middle values are more common than extreme values.
--
-- =====================================================================
local PAUSE_MIN_MS = 10000
local PAUSE_MAX_MS = 180000
-- =====================================================================
-- CIRCLE RADIUS
-- =====================================================================
local RADIUS_MIN_PX = 21.0
local RADIUS_MAX_PX = 27.0
-- =====================================================================
-- HUMAN MOVEMENT SPEED
-- =====================================================================
--
-- Speed is calculated from circle geometry:
--
-- circumference = 2 * pi * radius
--
-- speed = circumference * frequency
--
-- speed = 2 * pi * radius * frequency
--
--
-- Frequency range:
--
-- 0.65 .. 1.10 Hz
--
-- With radius:
--
-- 21 .. 27 px
--
-- the nominal average tangential range is approximately:
--
-- 85.8 .. 186.6 px/s
--
-- Additional correlated variation is applied during the circle.
--
-- =====================================================================
local MOVEMENT_FREQUENCY_MIN_HZ = 0.65
local MOVEMENT_FREQUENCY_MAX_HZ = 1.10
local FREQUENCY_CIRCLE_VARIATION_MIN_HZ = -0.085
local FREQUENCY_CIRCLE_VARIATION_MAX_HZ = 0.085
local HUMAN_SPEED_MIN_PX_PER_SEC = 75.0
local HUMAN_SPEED_MAX_PX_PER_SEC = 210.0
-- =====================================================================
-- INTERNAL SAMPLE TIMING
-- =====================================================================
local SAMPLE_MIN_MS = 7
local SAMPLE_MAX_MS = 11
-- =====================================================================
-- ELLIPTICITY
-- =====================================================================
local ELLIPTICITY_VARIATION_MIN = -0.018
local ELLIPTICITY_VARIATION_MAX = 0.018
local ELLIPTICITY_DRIFT_SD_MIN = 0.004
local ELLIPTICITY_DRIFT_SD_MAX = 0.012
local ELLIPTICITY_TAU_MIN = 4.0
local ELLIPTICITY_TAU_MAX = 7.0
-- =====================================================================
-- ELLIPSE ORIENTATION
-- =====================================================================
local ROTATION_VARIATION_MIN = -0.08
local ROTATION_VARIATION_MAX = 0.08
local ROTATION_DRIFT_SD_MIN = 0.006
local ROTATION_DRIFT_SD_MAX = 0.020
local ROTATION_TAU_MIN = 5.0
local ROTATION_TAU_MAX = 9.0
-- =====================================================================
-- SLOW RADIUS VARIATION
-- =====================================================================
local RADIUS_DRIFT_SD_MIN = 0.20
local RADIUS_DRIFT_SD_MAX = 0.55
local RADIUS_DRIFT_TAU_MIN = 2.0
local RADIUS_DRIFT_TAU_MAX = 4.0
-- =====================================================================
-- SPEED VARIATION DURING A CIRCLE
-- =====================================================================
local TEMPO_SD_MIN = 0.018
local TEMPO_SD_MAX = 0.045
local TEMPO_TAU_MIN = 0.55
local TEMPO_TAU_MAX = 1.10
local INTRA_CIRCLE_SPEED_MIN_FACTOR = 0.88
local INTRA_CIRCLE_SPEED_MAX_FACTOR = 1.12
-- =====================================================================
-- MOTOR NOISE
-- =====================================================================
local MOTOR_NOISE_SD_MIN = 0.16
local MOTOR_NOISE_SD_MAX = 0.36
local MOTOR_NOISE_TAU_MIN = 0.055
local MOTOR_NOISE_TAU_MAX = 0.120
-- =====================================================================
-- PHYSIOLOGICAL TREMOR
-- =====================================================================
local TREMOR_FREQ_MIN = 8.0
local TREMOR_FREQ_MAX = 12.0
local TREMOR_FREQ_CIRCLE_VARIATION = 0.25
local TREMOR_AMP_X_MIN = 0.18
local TREMOR_AMP_X_MAX = 0.38
local TREMOR_AMP_Y_MIN = 0.15
local TREMOR_AMP_Y_MAX = 0.34
local TREMOR_FREQ_DRIFT_SD_MIN = 0.12
local TREMOR_FREQ_DRIFT_SD_MAX = 0.32
local TREMOR_FREQ_DRIFT_TAU_MIN = 1.5
local TREMOR_FREQ_DRIFT_TAU_MAX = 2.8
local TREMOR_AMP_DRIFT_SD_MIN = 0.025
local TREMOR_AMP_DRIFT_SD_MAX = 0.060
local TREMOR_AMP_DRIFT_TAU_MIN = 1.0
local TREMOR_AMP_DRIFT_TAU_MAX = 2.0
-- =====================================================================
-- BUTTON POLLING
-- =====================================================================
local BUTTON_POLL_MS = 15
local TOGGLE_DEBOUNCE_MS = 180
-- =====================================================================
-- INTERNAL CONSTANTS AND STATE
-- =====================================================================
local TWO_PI = math.pi * 2.0
local PERSON_PRNG_MODULUS = 2147483647
local enabled = false
local running = false
local configurationValid = true
local ignoreToggleUntil = 0
local gaussianHasSpare = false
local gaussianSpare = 0
-- =====================================================================
-- BASIC HELPERS
-- =====================================================================
local function Clamp(value, minimum, maximum)
if value < minimum then
return minimum
end
if value > maximum then
return maximum
end
return value
end
local function RandomFloat(minimum, maximum)
return minimum + math.random() * (maximum - minimum)
end
-- Center-weighted random value.
local function HumanRandom(minimum, maximum)
local randomValue =
(
math.random() +
math.random() +
math.random()
) / 3.0
return minimum + randomValue * (maximum - minimum)
end
local function RoundSigned(value)
if value >= 0 then
return math.floor(value + 0.5)
end
return math.ceil(value - 0.5)
end
-- =====================================================================
-- TOGGLE CONFIGURATION VALIDATION
-- =====================================================================
local function IsToggleButtonSupported()
return
TOGGLE_BUTTON >= 1 and
TOGGLE_BUTTON <= 5
end
-- =====================================================================
-- PERSON SEED NORMALIZATION
-- =====================================================================
local function NormalizePersonSeed(seed)
seed = tonumber(seed) or 1
seed = math.floor(seed)
seed = seed % PERSON_PRNG_MODULUS
if seed <= 0 then
seed = 1
end
return seed
end
local NORMALIZED_PERSON_SEED =
NormalizePersonSeed(PERSON_SEED)
-- =====================================================================
-- GAUSSIAN RANDOM GENERATOR
-- =====================================================================
local function Gaussian()
if gaussianHasSpare then
gaussianHasSpare = false
return gaussianSpare
end
local u1 = 0.0
repeat
u1 = math.random()
until u1 > 0.000001
local u2 = math.random()
local magnitude =
math.sqrt(-2.0 * math.log(u1))
local phase =
TWO_PI * u2
gaussianSpare =
magnitude * math.sin(phase)
gaussianHasSpare = true
return
magnitude * math.cos(phase)
end
-- =====================================================================
-- TEMPORALLY CORRELATED NOISE
-- =====================================================================
local function CorrelatedNoise(
value,
tau,
standardDeviation,
deltaTime
)
local decay =
math.exp(-deltaTime / tau)
local randomScale =
standardDeviation *
math.sqrt(1.0 - decay * decay)
return
value * decay +
randomScale * Gaussian()
end
-- =====================================================================
-- DEDICATED PERSON PRNG
-- =====================================================================
local personState =
NORMALIZED_PERSON_SEED
local function PersonRandom01()
personState =
(
personState * 16807
) % PERSON_PRNG_MODULUS
return
personState / PERSON_PRNG_MODULUS
end
local function PersonRandom(minimum, maximum)
return
minimum +
PersonRandom01() *
(maximum - minimum)
end
local function PersonHumanRandom(minimum, maximum)
local randomValue =
(
PersonRandom01() +
PersonRandom01() +
PersonRandom01()
) / 3.0
return
minimum +
randomValue *
(maximum - minimum)
end
-- =====================================================================
-- PERSISTENT PERSON PROFILE
-- =====================================================================
local PERSON = {}
local function CreatePersonProfile()
personState =
NORMALIZED_PERSON_SEED
PERSON.radiusBias =
PersonHumanRandom(
0.97,
1.03
)
PERSON.frequencyPreference =
PersonHumanRandom(
0.30,
0.70
)
PERSON.ellipticity =
PersonHumanRandom(
-0.028,
0.028
)
PERSON.rotation =
PersonHumanRandom(
-0.18,
0.18
)
PERSON.noiseBias =
PersonHumanRandom(
0.90,
1.10
)
PERSON.tremorBias =
PersonHumanRandom(
0.90,
1.10
)
PERSON.xScale =
PersonHumanRandom(
0.985,
1.025
)
PERSON.yScale =
PersonHumanRandom(
0.975,
1.015
)
PERSON.tremorFreqX =
PersonHumanRandom(
8.5,
10.8
)
PERSON.tremorFreqY =
PersonHumanRandom(
8.7,
11.2
)
PERSON.harmonic2 =
PersonHumanRandom(
0.006,
0.018
)
PERSON.harmonic3 =
PersonHumanRandom(
0.0025,
0.009
)
PERSON.harmonic2Phase =
PersonRandom(
0.0,
TWO_PI
)
PERSON.harmonic3Phase =
PersonRandom(
0.0,
TWO_PI
)
if PersonRandom01() < 0.5 then
PERSON.direction = -1
else
PERSON.direction = 1
end
end
-- =====================================================================
-- DPI LED INDICATOR HELPERS
-- =====================================================================
local function PlayDpiUp()
PlayMacro(DPI_UP_MACRO)
Sleep(DPI_COMMAND_DELAY_MS)
end
local function PlayDpiDown()
PlayMacro(DPI_DOWN_MACRO)
Sleep(DPI_COMMAND_DELAY_MS)
end
local function ForceDpiProfile1()
-- Four downward commands guarantee reaching DPI/Profile 1 even
-- when starting from DPI/Profile 5.
for _ = 1, DPI_MAX_PROFILE_COUNT - 1 do
PlayDpiDown()
end
end
local function MoveProfile1ToProfile3()
for _ = 1, DPI_STEPS_TO_THREE_LEDS do
PlayDpiUp()
end
end
local function MoveProfile3ToProfile1()
for _ = 1, DPI_STEPS_TO_THREE_LEDS do
PlayDpiDown()
end
end
-- =====================================================================
-- ENABLED LED INDICATION
-- =====================================================================
--
-- Blink all three LEDs three times.
--
-- The mouse always returns to DPI/Profile 1 before cursor automation
-- starts.
--
-- =====================================================================
local function ShowEnabledIndicator()
ForceDpiProfile1()
for pulse = 1, LED_ON_PULSE_COUNT do
MoveProfile1ToProfile3()
Sleep(
math.random(
LED_ON_HOLD_MIN_MS,
LED_ON_HOLD_MAX_MS
)
)
MoveProfile3ToProfile1()
if pulse < LED_ON_PULSE_COUNT then
Sleep(
math.random(
LED_ON_GAP_MIN_MS,
LED_ON_GAP_MAX_MS
)
)
end
end
OutputLogMessage(
"[HumanJiggler] LED indicator: ON\n"
)
end
-- =====================================================================
-- DISABLED LED INDICATION
-- =====================================================================
--
-- Force DPI/Profile 1 so the one-LED pattern is displayed.
--
-- =====================================================================
local function ShowDisabledIndicator()
ForceDpiProfile1()
Sleep(
math.random(
LED_OFF_HOLD_MIN_MS,
LED_OFF_HOLD_MAX_MS
)
)
OutputLogMessage(
"[HumanJiggler] LED indicator: OFF\n"
)
end
-- =====================================================================
-- TOGGLE BUTTON HELPERS
-- =====================================================================
local function WaitForToggleRelease()
while IsMouseButtonPressed(TOGGLE_BUTTON) do
Sleep(5)
end
end
local function CheckToggleOff()
if not IsMouseButtonPressed(TOGGLE_BUTTON) then
return false
end
enabled = false
WaitForToggleRelease()
ignoreToggleUntil =
GetRunningTime() +
TOGGLE_DEBOUNCE_MS
ShowDisabledIndicator()
return true
end
-- =====================================================================
-- INTERRUPTIBLE SLEEP
-- =========================================
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment