ds4color 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ds4color-1.0.0/LICENSE +21 -0
- ds4color-1.0.0/PKG-INFO +109 -0
- ds4color-1.0.0/README.md +90 -0
- ds4color-1.0.0/ds4color/__init__.py +1 -0
- ds4color-1.0.0/ds4color/agent.py +67 -0
- ds4color-1.0.0/ds4color/cli.py +120 -0
- ds4color-1.0.0/ds4color/core.py +130 -0
- ds4color-1.0.0/ds4color/watch.py +50 -0
- ds4color-1.0.0/ds4color.egg-info/PKG-INFO +109 -0
- ds4color-1.0.0/ds4color.egg-info/SOURCES.txt +14 -0
- ds4color-1.0.0/ds4color.egg-info/dependency_links.txt +1 -0
- ds4color-1.0.0/ds4color.egg-info/entry_points.txt +2 -0
- ds4color-1.0.0/ds4color.egg-info/requires.txt +1 -0
- ds4color-1.0.0/ds4color.egg-info/top_level.txt +1 -0
- ds4color-1.0.0/pyproject.toml +30 -0
- ds4color-1.0.0/setup.cfg +4 -0
ds4color-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abhyudit Singh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ds4color-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ds4color
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Set the DualShock 4 lightbar to a solid colour on macOS over USB or Bluetooth
|
|
5
|
+
Author-email: Abhyudit Singh <abhyudit.singh@research.iiit.ac.in>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bitmap4/ds4-lightbar-macos
|
|
8
|
+
Project-URL: Issues, https://github.com/bitmap4/ds4-lightbar-macos/issues
|
|
9
|
+
Keywords: dualshock4,ds4,playstation,controller,lightbar,macos,hid
|
|
10
|
+
Classifier: Environment :: MacOS X
|
|
11
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Games/Entertainment
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: hidapi>=0.14
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# ds4-lightbar-macos
|
|
21
|
+
|
|
22
|
+
A DualShock 4 connected to a Mac over Bluetooth never gets told what colour to
|
|
23
|
+
make its lightbar, so it just pulses white forever even though it works fine.
|
|
24
|
+
On a PS4 the console sends that command; macOS doesn't. This is a tiny tool
|
|
25
|
+
that sends it, so the light goes solid instead.
|
|
26
|
+
|
|
27
|
+
It also sets the colour automatically every time the controller connects, over
|
|
28
|
+
USB or Bluetooth, and only after it has read a real input report from the pad.
|
|
29
|
+
That last part matters: if the light goes solid, the controller is genuinely
|
|
30
|
+
connected and sending input. If it stays blinking white, something is wrong
|
|
31
|
+
with the connection and you know not to trust it.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
Homebrew:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
brew install bitmap4/tap/ds4color
|
|
39
|
+
ds4color enable
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or with pipx:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pipx install ds4color
|
|
46
|
+
ds4color enable
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`ds4color enable` starts a small background agent that runs at login and sets
|
|
50
|
+
the colour whenever the controller connects. `ds4color disable` stops it.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ds4color # current setting and whether the pad is connected
|
|
56
|
+
ds4color blue # pick a colour
|
|
57
|
+
ds4color blue 20 # colour at 20% brightness
|
|
58
|
+
ds4color 20 # just the brightness
|
|
59
|
+
ds4color 255 90 0 # custom rgb
|
|
60
|
+
ds4color night # dim warm preset
|
|
61
|
+
ds4color day # bright blue preset
|
|
62
|
+
ds4color list # named colours
|
|
63
|
+
ds4color presets # saved presets
|
|
64
|
+
ds4color save night # save the current setting as a preset
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Changes apply right away if the pad is connected, and the agent reuses them on
|
|
68
|
+
every reconnect.
|
|
69
|
+
|
|
70
|
+
If you own more than one DS4 and want this to touch only one of them, connect
|
|
71
|
+
that one and run `ds4color bind`. `ds4color unbind` goes back to matching any
|
|
72
|
+
DualShock 4.
|
|
73
|
+
|
|
74
|
+
## Config
|
|
75
|
+
|
|
76
|
+
`~/.config/ds4color/config.json`:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"color": "blue",
|
|
81
|
+
"brightness": 100,
|
|
82
|
+
"controller_serial": null,
|
|
83
|
+
"presets": {
|
|
84
|
+
"day": { "color": "blue", "brightness": 100 },
|
|
85
|
+
"night": { "color": "warm", "brightness": 12 }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`controller_serial` is the controller's Bluetooth MAC (the DS4 reports the same
|
|
91
|
+
value over USB). Leave it `null` to match any DS4.
|
|
92
|
+
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
Setting the lightbar is a HID output report. Over USB it's report `0x05`; over
|
|
96
|
+
Bluetooth it's report `0x11` with a CRC32 the controller checks before it
|
|
97
|
+
listens. The tool figures out which transport you're on by looking at the input
|
|
98
|
+
report the pad sends back, then writes the matching one. That's the whole trick.
|
|
99
|
+
|
|
100
|
+
## Uninstall
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
ds4color disable
|
|
104
|
+
brew uninstall ds4color # or: pipx uninstall ds4color
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
ds4color-1.0.0/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# ds4-lightbar-macos
|
|
2
|
+
|
|
3
|
+
A DualShock 4 connected to a Mac over Bluetooth never gets told what colour to
|
|
4
|
+
make its lightbar, so it just pulses white forever even though it works fine.
|
|
5
|
+
On a PS4 the console sends that command; macOS doesn't. This is a tiny tool
|
|
6
|
+
that sends it, so the light goes solid instead.
|
|
7
|
+
|
|
8
|
+
It also sets the colour automatically every time the controller connects, over
|
|
9
|
+
USB or Bluetooth, and only after it has read a real input report from the pad.
|
|
10
|
+
That last part matters: if the light goes solid, the controller is genuinely
|
|
11
|
+
connected and sending input. If it stays blinking white, something is wrong
|
|
12
|
+
with the connection and you know not to trust it.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Homebrew:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
brew install bitmap4/tap/ds4color
|
|
20
|
+
ds4color enable
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or with pipx:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pipx install ds4color
|
|
27
|
+
ds4color enable
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`ds4color enable` starts a small background agent that runs at login and sets
|
|
31
|
+
the colour whenever the controller connects. `ds4color disable` stops it.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
ds4color # current setting and whether the pad is connected
|
|
37
|
+
ds4color blue # pick a colour
|
|
38
|
+
ds4color blue 20 # colour at 20% brightness
|
|
39
|
+
ds4color 20 # just the brightness
|
|
40
|
+
ds4color 255 90 0 # custom rgb
|
|
41
|
+
ds4color night # dim warm preset
|
|
42
|
+
ds4color day # bright blue preset
|
|
43
|
+
ds4color list # named colours
|
|
44
|
+
ds4color presets # saved presets
|
|
45
|
+
ds4color save night # save the current setting as a preset
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Changes apply right away if the pad is connected, and the agent reuses them on
|
|
49
|
+
every reconnect.
|
|
50
|
+
|
|
51
|
+
If you own more than one DS4 and want this to touch only one of them, connect
|
|
52
|
+
that one and run `ds4color bind`. `ds4color unbind` goes back to matching any
|
|
53
|
+
DualShock 4.
|
|
54
|
+
|
|
55
|
+
## Config
|
|
56
|
+
|
|
57
|
+
`~/.config/ds4color/config.json`:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"color": "blue",
|
|
62
|
+
"brightness": 100,
|
|
63
|
+
"controller_serial": null,
|
|
64
|
+
"presets": {
|
|
65
|
+
"day": { "color": "blue", "brightness": 100 },
|
|
66
|
+
"night": { "color": "warm", "brightness": 12 }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`controller_serial` is the controller's Bluetooth MAC (the DS4 reports the same
|
|
72
|
+
value over USB). Leave it `null` to match any DS4.
|
|
73
|
+
|
|
74
|
+
## How it works
|
|
75
|
+
|
|
76
|
+
Setting the lightbar is a HID output report. Over USB it's report `0x05`; over
|
|
77
|
+
Bluetooth it's report `0x11` with a CRC32 the controller checks before it
|
|
78
|
+
listens. The tool figures out which transport you're on by looking at the input
|
|
79
|
+
report the pad sends back, then writes the matching one. That's the whole trick.
|
|
80
|
+
|
|
81
|
+
## Uninstall
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
ds4color disable
|
|
85
|
+
brew uninstall ds4color # or: pipx uninstall ds4color
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
LABEL = "com.ds4color.agent"
|
|
6
|
+
PLIST = os.path.join(os.path.expanduser("~"), "Library", "LaunchAgents", LABEL + ".plist")
|
|
7
|
+
LOG = os.path.join(os.path.expanduser("~"), ".config", "ds4color", "watch.log")
|
|
8
|
+
|
|
9
|
+
PLIST_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
|
10
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
11
|
+
<plist version="1.0">
|
|
12
|
+
<dict>
|
|
13
|
+
<key>Label</key>
|
|
14
|
+
<string>{label}</string>
|
|
15
|
+
<key>ProgramArguments</key>
|
|
16
|
+
<array>
|
|
17
|
+
<string>{python}</string>
|
|
18
|
+
<string>-m</string>
|
|
19
|
+
<string>ds4color.watch</string>
|
|
20
|
+
</array>
|
|
21
|
+
<key>RunAtLoad</key>
|
|
22
|
+
<true/>
|
|
23
|
+
<key>KeepAlive</key>
|
|
24
|
+
<true/>
|
|
25
|
+
<key>ThrottleInterval</key>
|
|
26
|
+
<integer>5</integer>
|
|
27
|
+
<key>StandardOutPath</key>
|
|
28
|
+
<string>{log}</string>
|
|
29
|
+
<key>StandardErrorPath</key>
|
|
30
|
+
<string>{log}</string>
|
|
31
|
+
</dict>
|
|
32
|
+
</plist>
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _domain():
|
|
37
|
+
return "gui/%d" % os.getuid()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_running():
|
|
41
|
+
r = subprocess.run(["launchctl", "print", "%s/%s" % (_domain(), LABEL)],
|
|
42
|
+
capture_output=True)
|
|
43
|
+
return r.returncode == 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def enable():
|
|
47
|
+
os.makedirs(os.path.dirname(PLIST), exist_ok=True)
|
|
48
|
+
os.makedirs(os.path.dirname(LOG), exist_ok=True)
|
|
49
|
+
with open(PLIST, "w") as f:
|
|
50
|
+
f.write(PLIST_TEMPLATE.format(label=LABEL, python=sys.executable, log=LOG))
|
|
51
|
+
subprocess.run(["launchctl", "bootout", "%s/%s" % (_domain(), LABEL)], capture_output=True)
|
|
52
|
+
r = subprocess.run(["launchctl", "bootstrap", _domain(), PLIST], capture_output=True, text=True)
|
|
53
|
+
if r.returncode != 0:
|
|
54
|
+
print(r.stderr.strip() or "failed to start the agent")
|
|
55
|
+
return 1
|
|
56
|
+
print("agent enabled, runs at login")
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def disable():
|
|
61
|
+
subprocess.run(["launchctl", "bootout", "%s/%s" % (_domain(), LABEL)], capture_output=True)
|
|
62
|
+
try:
|
|
63
|
+
os.remove(PLIST)
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
pass
|
|
66
|
+
print("agent disabled")
|
|
67
|
+
return 0
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from . import core
|
|
4
|
+
from . import agent
|
|
5
|
+
|
|
6
|
+
USAGE = """ds4color - set the DS4 lightbar color
|
|
7
|
+
|
|
8
|
+
ds4color show current setting and status
|
|
9
|
+
ds4color list list named colors
|
|
10
|
+
ds4color blue set a color
|
|
11
|
+
ds4color blue 20 set color and brightness (percent)
|
|
12
|
+
ds4color 20 set brightness only
|
|
13
|
+
ds4color 255 90 0 custom rgb
|
|
14
|
+
ds4color 255 90 0 40 custom rgb and brightness
|
|
15
|
+
ds4color night load the night preset
|
|
16
|
+
ds4color day load the day preset
|
|
17
|
+
ds4color presets list presets
|
|
18
|
+
ds4color save night save the current setting as a preset
|
|
19
|
+
ds4color bind lock to the controller connected right now
|
|
20
|
+
ds4color unbind allow any DualShock 4
|
|
21
|
+
ds4color enable start the background agent (sets color on connect)
|
|
22
|
+
ds4color disable stop the background agent
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def status():
|
|
27
|
+
cfg = core.load_config()
|
|
28
|
+
bound = cfg.get("controller_serial")
|
|
29
|
+
print("color:", cfg["color"])
|
|
30
|
+
print("brightness:", str(cfg["brightness"]) + "%")
|
|
31
|
+
print("rgb:", core.resolve_rgb(cfg["color"], cfg["brightness"]))
|
|
32
|
+
print("bound to:", bound if bound else "any DualShock 4")
|
|
33
|
+
print("controller:", "connected" if core.find_target(bound) else "not connected")
|
|
34
|
+
print("agent:", "running" if agent.is_running() else "stopped")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_int(s):
|
|
38
|
+
try:
|
|
39
|
+
int(s)
|
|
40
|
+
return True
|
|
41
|
+
except ValueError:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
args = sys.argv[1:]
|
|
47
|
+
cfg = core.load_config()
|
|
48
|
+
|
|
49
|
+
if not args:
|
|
50
|
+
status()
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
cmd = args[0].lower()
|
|
54
|
+
if cmd in ("list", "colors"):
|
|
55
|
+
print(", ".join(sorted(core.PALETTE)))
|
|
56
|
+
return 0
|
|
57
|
+
if cmd in ("status", "-s"):
|
|
58
|
+
status()
|
|
59
|
+
return 0
|
|
60
|
+
if cmd == "enable":
|
|
61
|
+
return agent.enable()
|
|
62
|
+
if cmd == "disable":
|
|
63
|
+
return agent.disable()
|
|
64
|
+
if cmd in ("presets", "preset"):
|
|
65
|
+
for name, p in cfg["presets"].items():
|
|
66
|
+
print(name, p["color"], str(p["brightness"]) + "%")
|
|
67
|
+
return 0
|
|
68
|
+
if cmd == "bind":
|
|
69
|
+
dev = core.find_target(None)
|
|
70
|
+
if not dev:
|
|
71
|
+
print("no controller connected")
|
|
72
|
+
return 3
|
|
73
|
+
cfg["controller_serial"] = dev.get("serial_number") or ""
|
|
74
|
+
core.save_config(cfg)
|
|
75
|
+
print("bound to", cfg["controller_serial"] or "(empty serial)")
|
|
76
|
+
return 0
|
|
77
|
+
if cmd == "unbind":
|
|
78
|
+
cfg["controller_serial"] = None
|
|
79
|
+
core.save_config(cfg)
|
|
80
|
+
print("unbound")
|
|
81
|
+
return 0
|
|
82
|
+
if cmd == "save" and len(args) >= 2:
|
|
83
|
+
name = args[1].lower()
|
|
84
|
+
cfg["presets"][name] = {"color": cfg["color"], "brightness": cfg["brightness"]}
|
|
85
|
+
core.save_config(cfg)
|
|
86
|
+
print("saved preset", name)
|
|
87
|
+
return 0
|
|
88
|
+
if cmd in cfg["presets"]:
|
|
89
|
+
cfg.update(cfg["presets"][cmd])
|
|
90
|
+
core.save_config(cfg)
|
|
91
|
+
print(cmd, "->", core.resolve_rgb(cfg["color"], cfg["brightness"]))
|
|
92
|
+
print(core.apply_from_config()[1])
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
ints = [int(a) for a in args if is_int(a)]
|
|
96
|
+
names = [a for a in args if not is_int(a)]
|
|
97
|
+
|
|
98
|
+
if names and names[0].lower() in core.PALETTE:
|
|
99
|
+
cfg["color"] = names[0].lower()
|
|
100
|
+
if ints:
|
|
101
|
+
cfg["brightness"] = ints[0]
|
|
102
|
+
elif len(ints) >= 3:
|
|
103
|
+
cfg["color"] = ints[:3]
|
|
104
|
+
if len(ints) >= 4:
|
|
105
|
+
cfg["brightness"] = ints[3]
|
|
106
|
+
elif len(ints) == 1:
|
|
107
|
+
cfg["brightness"] = ints[0]
|
|
108
|
+
else:
|
|
109
|
+
print(USAGE)
|
|
110
|
+
return 2
|
|
111
|
+
|
|
112
|
+
cfg["brightness"] = max(0, min(100, int(cfg["brightness"])))
|
|
113
|
+
core.save_config(cfg)
|
|
114
|
+
print("rgb:", core.resolve_rgb(cfg["color"], cfg["brightness"]))
|
|
115
|
+
print(core.apply_from_config()[1])
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sys.exit(main())
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import zlib
|
|
5
|
+
|
|
6
|
+
import hid
|
|
7
|
+
|
|
8
|
+
VENDOR_ID = 0x054C
|
|
9
|
+
PRODUCT_IDS = {0x05C4, 0x09CC} # DS4 v1 and v2
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "ds4color")
|
|
12
|
+
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
|
|
13
|
+
|
|
14
|
+
PALETTE = {
|
|
15
|
+
"blue": (0, 0, 255),
|
|
16
|
+
"red": (255, 0, 0),
|
|
17
|
+
"green": (0, 255, 0),
|
|
18
|
+
"cyan": (0, 255, 255),
|
|
19
|
+
"magenta": (255, 0, 255),
|
|
20
|
+
"pink": (255, 40, 120),
|
|
21
|
+
"purple": (140, 0, 255),
|
|
22
|
+
"orange": (255, 90, 0),
|
|
23
|
+
"yellow": (255, 200, 0),
|
|
24
|
+
"white": (255, 255, 255),
|
|
25
|
+
"warm": (255, 120, 30),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_config():
|
|
30
|
+
try:
|
|
31
|
+
with open(CONFIG_PATH) as f:
|
|
32
|
+
cfg = json.load(f)
|
|
33
|
+
except Exception:
|
|
34
|
+
cfg = {}
|
|
35
|
+
cfg.setdefault("color", "blue")
|
|
36
|
+
cfg.setdefault("brightness", 100)
|
|
37
|
+
cfg.setdefault("controller_serial", None)
|
|
38
|
+
presets = cfg.setdefault("presets", {})
|
|
39
|
+
presets.setdefault("day", {"color": "blue", "brightness": 100})
|
|
40
|
+
presets.setdefault("night", {"color": "warm", "brightness": 12})
|
|
41
|
+
return cfg
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def save_config(cfg):
|
|
45
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
46
|
+
with open(CONFIG_PATH, "w") as f:
|
|
47
|
+
json.dump(cfg, f, indent=2)
|
|
48
|
+
f.write("\n")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_rgb(color, brightness):
|
|
52
|
+
base = PALETTE.get(color.lower(), PALETTE["blue"]) if isinstance(color, str) else tuple(int(c) for c in color)
|
|
53
|
+
scale = max(0, min(100, int(brightness))) / 100.0
|
|
54
|
+
return tuple(max(0, min(255, round(c * scale))) for c in base)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_target(serial=None):
|
|
58
|
+
want = (serial or "").upper() or None
|
|
59
|
+
for d in hid.enumerate():
|
|
60
|
+
if d.get("vendor_id") == VENDOR_ID and d.get("product_id") in PRODUCT_IDS:
|
|
61
|
+
ser = (d.get("serial_number") or "").upper()
|
|
62
|
+
if want is None or ser == want:
|
|
63
|
+
return d
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _usb_report(r, g, b):
|
|
68
|
+
buf = bytearray(32)
|
|
69
|
+
buf[0] = 0x05
|
|
70
|
+
buf[1] = 0xFF
|
|
71
|
+
buf[6], buf[7], buf[8] = r, g, b
|
|
72
|
+
return bytes(buf)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _bt_report(r, g, b):
|
|
76
|
+
buf = bytearray(78)
|
|
77
|
+
buf[0] = 0x11
|
|
78
|
+
buf[1] = 0xC0
|
|
79
|
+
buf[3] = 0x07
|
|
80
|
+
buf[8], buf[9], buf[10] = r, g, b
|
|
81
|
+
# DS4 checks a CRC32 over a 0xA2 prefix plus the report, or it ignores us
|
|
82
|
+
crc = zlib.crc32(bytes([0xA2]) + bytes(buf[0:74])) & 0xFFFFFFFF
|
|
83
|
+
buf[74:78] = crc.to_bytes(4, "little")
|
|
84
|
+
return bytes(buf)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _read_input(h, attempts=12, timeout_ms=200):
|
|
88
|
+
for _ in range(attempts):
|
|
89
|
+
try:
|
|
90
|
+
data = h.read(128, timeout_ms)
|
|
91
|
+
except TypeError:
|
|
92
|
+
h.set_nonblocking(False)
|
|
93
|
+
data = h.read(128)
|
|
94
|
+
if data and len(data) >= 10:
|
|
95
|
+
return data[0], len(data)
|
|
96
|
+
time.sleep(0.02)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def apply(rgb, serial=None):
|
|
101
|
+
d = find_target(serial)
|
|
102
|
+
if not d:
|
|
103
|
+
return 3, "controller not found"
|
|
104
|
+
|
|
105
|
+
h = hid.device()
|
|
106
|
+
try:
|
|
107
|
+
try:
|
|
108
|
+
h.open_path(d["path"])
|
|
109
|
+
except Exception:
|
|
110
|
+
h.open(VENDOR_ID, d["product_id"])
|
|
111
|
+
except Exception as e:
|
|
112
|
+
return 5, "could not open device: %s" % e
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
rid = _read_input(h)
|
|
116
|
+
if rid is None:
|
|
117
|
+
return 4, "connected but no input yet, leaving the light alone"
|
|
118
|
+
report_id, length = rid
|
|
119
|
+
over_bt = report_id == 0x11 or length >= 70
|
|
120
|
+
rep = _bt_report(*rgb) if over_bt else _usb_report(*rgb)
|
|
121
|
+
if h.write(rep) <= 0:
|
|
122
|
+
return 6, "input works but writing the color failed"
|
|
123
|
+
return 0, "%s ok, set RGB%s" % ("bluetooth" if over_bt else "usb", rgb)
|
|
124
|
+
finally:
|
|
125
|
+
h.close()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def apply_from_config():
|
|
129
|
+
cfg = load_config()
|
|
130
|
+
return apply(resolve_rgb(cfg["color"], cfg["brightness"]), cfg.get("controller_serial"))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from . import core
|
|
6
|
+
|
|
7
|
+
POLL = 1.5
|
|
8
|
+
RETRY_WINDOW = 20
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def log(msg):
|
|
12
|
+
print("[%s] %s" % (datetime.now().strftime("%H:%M:%S"), msg), flush=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
log("watching")
|
|
17
|
+
present = False
|
|
18
|
+
applied = False
|
|
19
|
+
deadline = 0.0
|
|
20
|
+
while True:
|
|
21
|
+
cfg = core.load_config()
|
|
22
|
+
here = core.find_target(cfg.get("controller_serial")) is not None
|
|
23
|
+
|
|
24
|
+
if here and not present:
|
|
25
|
+
present, applied = True, False
|
|
26
|
+
deadline = time.time() + RETRY_WINDOW
|
|
27
|
+
elif not here and present:
|
|
28
|
+
present, applied = False, False
|
|
29
|
+
log("disconnected")
|
|
30
|
+
|
|
31
|
+
if present and not applied:
|
|
32
|
+
code, msg = core.apply_from_config()
|
|
33
|
+
if code == 0:
|
|
34
|
+
log(msg)
|
|
35
|
+
applied = True
|
|
36
|
+
elif time.time() > deadline:
|
|
37
|
+
log("gave up: " + msg)
|
|
38
|
+
applied = True
|
|
39
|
+
|
|
40
|
+
time.sleep(POLL)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
try:
|
|
45
|
+
main()
|
|
46
|
+
except KeyboardInterrupt:
|
|
47
|
+
pass
|
|
48
|
+
except Exception as e:
|
|
49
|
+
log("crashed: %s" % e)
|
|
50
|
+
sys.exit(1)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ds4color
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Set the DualShock 4 lightbar to a solid colour on macOS over USB or Bluetooth
|
|
5
|
+
Author-email: Abhyudit Singh <abhyudit.singh@research.iiit.ac.in>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bitmap4/ds4-lightbar-macos
|
|
8
|
+
Project-URL: Issues, https://github.com/bitmap4/ds4-lightbar-macos/issues
|
|
9
|
+
Keywords: dualshock4,ds4,playstation,controller,lightbar,macos,hid
|
|
10
|
+
Classifier: Environment :: MacOS X
|
|
11
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Games/Entertainment
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: hidapi>=0.14
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# ds4-lightbar-macos
|
|
21
|
+
|
|
22
|
+
A DualShock 4 connected to a Mac over Bluetooth never gets told what colour to
|
|
23
|
+
make its lightbar, so it just pulses white forever even though it works fine.
|
|
24
|
+
On a PS4 the console sends that command; macOS doesn't. This is a tiny tool
|
|
25
|
+
that sends it, so the light goes solid instead.
|
|
26
|
+
|
|
27
|
+
It also sets the colour automatically every time the controller connects, over
|
|
28
|
+
USB or Bluetooth, and only after it has read a real input report from the pad.
|
|
29
|
+
That last part matters: if the light goes solid, the controller is genuinely
|
|
30
|
+
connected and sending input. If it stays blinking white, something is wrong
|
|
31
|
+
with the connection and you know not to trust it.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
Homebrew:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
brew install bitmap4/tap/ds4color
|
|
39
|
+
ds4color enable
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or with pipx:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pipx install ds4color
|
|
46
|
+
ds4color enable
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`ds4color enable` starts a small background agent that runs at login and sets
|
|
50
|
+
the colour whenever the controller connects. `ds4color disable` stops it.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ds4color # current setting and whether the pad is connected
|
|
56
|
+
ds4color blue # pick a colour
|
|
57
|
+
ds4color blue 20 # colour at 20% brightness
|
|
58
|
+
ds4color 20 # just the brightness
|
|
59
|
+
ds4color 255 90 0 # custom rgb
|
|
60
|
+
ds4color night # dim warm preset
|
|
61
|
+
ds4color day # bright blue preset
|
|
62
|
+
ds4color list # named colours
|
|
63
|
+
ds4color presets # saved presets
|
|
64
|
+
ds4color save night # save the current setting as a preset
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Changes apply right away if the pad is connected, and the agent reuses them on
|
|
68
|
+
every reconnect.
|
|
69
|
+
|
|
70
|
+
If you own more than one DS4 and want this to touch only one of them, connect
|
|
71
|
+
that one and run `ds4color bind`. `ds4color unbind` goes back to matching any
|
|
72
|
+
DualShock 4.
|
|
73
|
+
|
|
74
|
+
## Config
|
|
75
|
+
|
|
76
|
+
`~/.config/ds4color/config.json`:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"color": "blue",
|
|
81
|
+
"brightness": 100,
|
|
82
|
+
"controller_serial": null,
|
|
83
|
+
"presets": {
|
|
84
|
+
"day": { "color": "blue", "brightness": 100 },
|
|
85
|
+
"night": { "color": "warm", "brightness": 12 }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`controller_serial` is the controller's Bluetooth MAC (the DS4 reports the same
|
|
91
|
+
value over USB). Leave it `null` to match any DS4.
|
|
92
|
+
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
Setting the lightbar is a HID output report. Over USB it's report `0x05`; over
|
|
96
|
+
Bluetooth it's report `0x11` with a CRC32 the controller checks before it
|
|
97
|
+
listens. The tool figures out which transport you're on by looking at the input
|
|
98
|
+
report the pad sends back, then writes the matching one. That's the whole trick.
|
|
99
|
+
|
|
100
|
+
## Uninstall
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
ds4color disable
|
|
104
|
+
brew uninstall ds4color # or: pipx uninstall ds4color
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
ds4color/__init__.py
|
|
5
|
+
ds4color/agent.py
|
|
6
|
+
ds4color/cli.py
|
|
7
|
+
ds4color/core.py
|
|
8
|
+
ds4color/watch.py
|
|
9
|
+
ds4color.egg-info/PKG-INFO
|
|
10
|
+
ds4color.egg-info/SOURCES.txt
|
|
11
|
+
ds4color.egg-info/dependency_links.txt
|
|
12
|
+
ds4color.egg-info/entry_points.txt
|
|
13
|
+
ds4color.egg-info/requires.txt
|
|
14
|
+
ds4color.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hidapi>=0.14
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ds4color
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ds4color"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Set the DualShock 4 lightbar to a solid colour on macOS over USB or Bluetooth"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Abhyudit Singh", email = "abhyudit.singh@research.iiit.ac.in" }]
|
|
13
|
+
keywords = ["dualshock4", "ds4", "playstation", "controller", "lightbar", "macos", "hid"]
|
|
14
|
+
dependencies = ["hidapi>=0.14"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Environment :: MacOS X",
|
|
17
|
+
"Operating System :: MacOS :: MacOS X",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Games/Entertainment",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/bitmap4/ds4-lightbar-macos"
|
|
24
|
+
Issues = "https://github.com/bitmap4/ds4-lightbar-macos/issues"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
ds4color = "ds4color.cli:main"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
packages = ["ds4color"]
|
ds4color-1.0.0/setup.cfg
ADDED