@airnexus/node-red-contrib-matter-airnexus 0.2.4-airnexus.1

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.
@@ -0,0 +1,327 @@
1
+ # Node-RED Matter Dynamic - Architecture Documentation
2
+
3
+ ## Project Overview
4
+
5
+ This project implements dynamic Matter device nodes for Node-RED, allowing users to create Matter devices by specifying only the device type in JSON configuration, without hardcoding device-specific characteristics.
6
+
7
+ ## Key Components
8
+
9
+ ### 1. Matter Bridge (`matter-bridge.js` / `matter-bridge.html`)
10
+ - **Purpose**: Creates a Matter Bridge that hosts multiple dynamic devices
11
+ - **Key Features**:
12
+ - Manages Matter server lifecycle
13
+ - Handles device registration via `registerChild()` function
14
+ - Provides HTTP endpoints for commissioning QR codes
15
+ - Auto-starts when all registered devices are ready
16
+
17
+ ### 2. Matter Device (`matter-device.js` / `matter-device.html`)
18
+ - **Purpose**: Generic node that can represent any Matter device type
19
+ - **Configuration**: Simple JSON specifying only `deviceType` (e.g., "OnOffLightDevice")
20
+ - **Key Features**:
21
+ - Dynamically subscribes to all device events
22
+ - Maps Matter.js cluster structure for input/output
23
+ - No hardcoded device-specific logic
24
+
25
+ ## Architecture Design
26
+
27
+ ### CRITICAL DESIGN PRINCIPLE: NO HARDCODED DEVICE-SPECIFIC LOGIC
28
+ **This system MUST remain completely generic. NEVER hardcode behaviors, attributes, or logic for specific device types. All device capabilities must be discovered and handled dynamically at runtime.**
29
+
30
+ ### Device Creation Flow
31
+ ```
32
+ 1. User configures device with JSON: {"deviceType": "OnOffLightDevice"}
33
+ 2. Device node waits for bridge to be ready
34
+ 3. Creates Matter.js Endpoint with specified device type
35
+ 4. Registers with bridge using registerChild()
36
+ 5. Bridge adds device to aggregator
37
+ 6. On server ready, device subscribes to all events dynamically
38
+ ```
39
+
40
+ ### Event System
41
+ - **Input**: Expects Matter.js cluster format (e.g., `{onOff: {onOff: true}}`)
42
+ - **Output**: Emits changes in same format when device state changes
43
+ - **Dynamic Subscription**: Iterates through all clusters and subscribes to `$Changed` events
44
+
45
+ ### Key Differences from Static Approach
46
+ - No separate node for each device type
47
+ - No hardcoded cluster names or attributes
48
+ - All device capabilities discovered at runtime
49
+ - Single codebase handles all Matter device types
50
+
51
+ ## Current Issues & Solutions
52
+
53
+ ### ✅ Issue 4: Video Player Commands Not Implemented (RESOLVED)
54
+ - **Symptom**: Matter.js logs "Throws unimplemented exception" for play/pause/stop/sendKey
55
+ - **Root Cause**: Behaviors with commands require method implementations
56
+ - **Solution**: Dynamically patch ALL behavior prototypes at module load time:
57
+ - Iterate through all behaviors that have cluster.commands
58
+ - Add command method implementations before devices are loaded
59
+ - Each method sends command to Node-RED and returns appropriate response
60
+ ```javascript
61
+ // Patch behaviors before loading devices
62
+ Object.values(matterBehaviors).forEach(BehaviorClass => {
63
+ if (BehaviorClass?.cluster?.commands) {
64
+ Object.entries(BehaviorClass.cluster.commands).forEach(([cmd, def]) => {
65
+ BehaviorClass.prototype[cmd] = async function(request) {
66
+ // Send to Node-RED
67
+ // Return success
68
+ };
69
+ });
70
+ }
71
+ });
72
+ ```
73
+ - **Note**: Video player devices are part of Matter 1.4 spec but may not be supported by all controllers yet.
74
+
75
+ ### ✅ Issue 1: Events Not Firing (RESOLVED)
76
+ - **Symptom**: Matter.js logs showed commands but Node-RED didn't emit messages
77
+ - **Root Cause**: Event properties ending with `$Changed` are not enumerable in Matter.js
78
+ - **Solution**: Subscribe to events based on state attributes instead of iterating event properties
79
+ ```javascript
80
+ // Instead of iterating events, use state attributes:
81
+ if (node.device.state && node.device.state[clusterName]) {
82
+ Object.keys(node.device.state[clusterName]).forEach(attributeName => {
83
+ const eventName = `${attributeName}$Changed`;
84
+ if (clusterEvents[eventName]) {
85
+ clusterEvents[eventName].on(handler);
86
+ }
87
+ });
88
+ }
89
+ ```
90
+
91
+ ### ✅ Issue 2: Multiple Event Emissions (RESOLVED)
92
+ - **Symptom**: Each HomeKit action triggered 3 identical messages
93
+ - **Root Cause**: `serverReady` event emitted multiple times when devices register
94
+ - **Solution**: Added flag to prevent multiple subscriptions
95
+ ```javascript
96
+ if (node.eventsSubscribed) {
97
+ return; // Prevent duplicate subscriptions
98
+ }
99
+ node.eventsSubscribed = true;
100
+ ```
101
+
102
+ ## Code Structure
103
+
104
+ ### Bridge Registration
105
+ ```javascript
106
+ // Bridge exposes function
107
+ node.registerChild = function(child) {
108
+ // Add to registered devices
109
+ // Add device to aggregator
110
+ // Start server when ready
111
+ }
112
+
113
+ // Device calls it
114
+ node.bridge.registerChild(node);
115
+ ```
116
+
117
+ ### Dynamic Event Subscription
118
+ ```javascript
119
+ // Events ending with $Changed are not enumerable, so we use state attributes
120
+ Object.keys(node.device.events).forEach(clusterName => {
121
+ if (node.device.state && node.device.state[clusterName]) {
122
+ Object.keys(node.device.state[clusterName]).forEach(attributeName => {
123
+ const eventName = `${attributeName}$Changed`;
124
+ if (node.device.events[clusterName][eventName]) {
125
+ // Subscribe to state change
126
+ node.device.events[clusterName][eventName].on((value, oldValue, context) => {
127
+ const msg = { payload: {} };
128
+ msg.payload[clusterName] = {};
129
+ msg.payload[clusterName][attributeName] = value;
130
+ node.send(msg);
131
+ });
132
+ }
133
+ });
134
+ }
135
+ });
136
+ ```
137
+
138
+ ### Event Cleanup
139
+ Proper cleanup on node close to prevent memory leaks:
140
+ ```javascript
141
+ // Store handlers for cleanup
142
+ node.eventHandlers[`${clusterName}.${eventName}`] = handler;
143
+
144
+ // On close, remove all event listeners
145
+ for (const [handlerKey, handler] of Object.entries(node.eventHandlers)) {
146
+ const [clusterName, eventName] = handlerKey.split('.');
147
+ await node.device.events[clusterName][eventName].off(handler);
148
+ }
149
+ node.removeAllListeners('serverReady');
150
+ ```
151
+
152
+ ## Payload Format Examples
153
+
154
+ ### Light Control
155
+ ```javascript
156
+ // Input to turn on
157
+ msg.payload = {onOff: {onOff: true}}
158
+
159
+ // Output when state changes
160
+ msg.payload = {onOff: {onOff: false}}
161
+ ```
162
+
163
+ ### Temperature Sensor
164
+ ```javascript
165
+ // Input to set temperature (21.5°C)
166
+ msg.payload = {temperatureMeasurement: {measuredValue: 2150}}
167
+ ```
168
+
169
+ ## Key Implementation Details
170
+
171
+ ### Event Discovery
172
+ Matter.js events ending with `$Changed` are not enumerable properties. The solution uses the device state to discover available attributes and then checks for corresponding events:
173
+
174
+ 1. Iterate through `node.device.state` clusters
175
+ 2. For each attribute in the state, check if `${attribute}$Changed` event exists
176
+ 3. Subscribe only to existing events
177
+
178
+ ### Performance Considerations
179
+ - Single event subscription per attribute (prevented by flag)
180
+ - Proper cleanup prevents memory leaks
181
+ - 2-second delay after `serverReady` ensures Matter.js initialization
182
+
183
+ ### ✅ Issue 3: Matter.js Validation Errors Crash Node-RED (RESOLVED)
184
+ - **Symptom**: Node-RED crashes when creating devices with missing mandatory attributes
185
+ - **Root Cause**: Unhandled Promise rejection from Matter.js validation
186
+ - **Solution**: Added unhandled rejection handler in bridge to catch Matter.js errors
187
+ ```javascript
188
+ process.on('unhandledRejection', (reason, promise) => {
189
+ if (reason && reason.message && reason.message.includes('Behaviors have errors')) {
190
+ // Handle gracefully, mark device as failed
191
+ // Prevent Node-RED crash
192
+ }
193
+ });
194
+ ```
195
+
196
+ ## Command Handling Architecture
197
+
198
+ ### Dynamic Command Interception
199
+ The system dynamically intercepts ALL commands for ANY device type without hardcoding:
200
+ 1. At module load time, patches all behavior prototypes that have commands
201
+ 2. Each command method sends message to Node-RED when invoked
202
+ 3. Returns appropriate Matter response based on command schema
203
+ 4. Works for all current and future device types
204
+
205
+ ### Two-Output System
206
+ Device nodes have two outputs for different message types:
207
+ - **Output 1**: State change events (when attributes change)
208
+ - **Output 2**: Commands received from Matter controllers
209
+
210
+ This separation allows:
211
+ - Backward compatibility with existing flows using events
212
+ - Clear distinction between state changes and commands
213
+ - Easy routing of different message types
214
+
215
+ Example messages:
216
+ ```javascript
217
+ // Output 1 - Event
218
+ { onOff: { onOff: true } }
219
+
220
+ // Output 2 - Command
221
+ { command: "on", cluster: "OnOff", data: undefined }
222
+ ```
223
+
224
+ ### Controller Support Status
225
+ - **Fully Supported**: Lights, Switches, Sensors, Thermostats, Locks
226
+ - **Limited Support**:
227
+ - Video Players (Matter 1.4) - Not recognized by HomeKit/Tuya yet
228
+ - Advanced features may require specific controller support
229
+
230
+ ## Enhanced Devices Architecture
231
+
232
+ ### Overview
233
+ The system supports creating enhanced devices by adding additional behaviors/clusters to a primary device type using the `additionalBehaviors` configuration.
234
+
235
+ ### Enhanced Device Implementation
236
+ Adds extra functionality to a primary device type:
237
+ ```javascript
238
+ {
239
+ "deviceType": "ThermostatDevice",
240
+ "additionalBehaviors": ["PowerSourceServer", "RelativeHumidityMeasurementServer"]
241
+ }
242
+ ```
243
+
244
+ ### How It Works
245
+ 1. **Primary Device Type**: The main device type (e.g., ThermostatDevice)
246
+ 2. **Additional Behaviors**: Extra clusters added to the same endpoint
247
+ 3. **Behavior Aggregation**: All behaviors are merged into a single endpoint
248
+ 4. **Single Endpoint**: Everything runs on one BridgedNodeEndpoint
249
+
250
+ ### Example: Thermostat with Battery and Humidity
251
+ ```javascript
252
+ // Configuration
253
+ {
254
+ "deviceType": "ThermostatDevice",
255
+ "additionalBehaviors": ["PowerSourceServer", "RelativeHumidityMeasurementServer"],
256
+ "behaviorFeatures": {
257
+ "Thermostat": ["Heating", "Cooling"],
258
+ "PowerSource": ["Battery", "Replaceable"],
259
+ "RelativeHumidityMeasurement": ["Percentage"]
260
+ },
261
+ "initialState": {
262
+ "thermostat": {
263
+ "localTemperature": 2000,
264
+ "systemMode": 4,
265
+ "occupiedHeatingSetpoint": 2000
266
+ },
267
+ "powerSource": {
268
+ "batPercentRemaining": 100,
269
+ "batChargeLevel": 1
270
+ },
271
+ "relativeHumidityMeasurement": {
272
+ "measuredValue": 5000
273
+ }
274
+ }
275
+ }
276
+
277
+ // Results in single endpoint with:
278
+ // - ThermostatServer (with Heating/Cooling features)
279
+ // - PowerSourceServer (with Battery features)
280
+ // - RelativeHumidityMeasurementServer
281
+ // - BridgedDeviceBasicInformationServer
282
+ // - IdentifyServer
283
+ ```
284
+
285
+ ### Benefits
286
+ - **Simple**: One endpoint manages all functionality
287
+ - **Compatible**: Works with all Matter controllers
288
+ - **Efficient**: No complex child endpoint management
289
+ - **Flexible**: Add only the behaviors you need
290
+
291
+ ### Event Handling
292
+ All events come from the same endpoint:
293
+ ```javascript
294
+ // Thermostat event
295
+ { payload: { thermostat: { localTemperature: 2150 } } }
296
+
297
+ // Battery event
298
+ { payload: { powerSource: { batPercentRemaining: 90 } } }
299
+
300
+ // Humidity event
301
+ { payload: { relativeHumidityMeasurement: { measuredValue: 6000 } } }
302
+ ```
303
+
304
+ ## Next Steps
305
+
306
+ 1. **Add More Examples**: Create comprehensive examples for all device types
307
+ 2. **Error Handling**: Better error messages for invalid configurations
308
+ - **Future Enhancement**: Parse Matter.js validation warnings to show specific missing attributes
309
+ - Example: Extract `fanModeSequence`, `percentCurrent` from validation logs
310
+ - Goal: Show user-friendly list of missing mandatory attributes
311
+ 3. **Documentation**: Expand user documentation with more device examples
312
+ 4. **Testing**: Add automated tests for various device types including composite devices
313
+ 5. **Generic Command Handler**: Extend dynamic command handling to all clusters with commands
314
+
315
+ ## Dependencies
316
+
317
+ - `@matter/main`: Core Matter.js implementation
318
+ - Node-RED >= 3.0.0
319
+ - Node.js >= 18.0.0
320
+
321
+ ## Testing
322
+
323
+ Current test flow in `examples/flows.json` includes:
324
+ - OnOffLightDevice
325
+ - TemperatureSensorDevice
326
+
327
+ Both should respond to HomeKit commands and emit state changes to Node-RED.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 faxioman
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.
package/README.md ADDED
@@ -0,0 +1,274 @@
1
+ AirNexus Node-RED Matter Dynamic
2
+
3
+ Dynamic Matter bridge + dynamic devices for Node-RED. Create any Matter device by specifying the device type in JSON configuration — no need for device-specific nodes.
4
+
5
+ This AirNexus fork adds:
6
+
7
+ Matter Pairing node (get QR/manual codes, reopen pairing, factory reset commissioning)
8
+
9
+ Matter Device “hidden until enabled” mode (devices don’t appear in Alexa/HomeKit until you enable them by payload)
10
+
11
+ Dynamic rename by payload
12
+
13
+ Thermostat-friendly event handling (captures attribute writes even when controllers don’t send explicit commands)
14
+
15
+ Installation
16
+ npm install @airnexus/node-red-contrib-matter-airnexus
17
+
18
+ Requirements
19
+
20
+ Node.js >= 18.0.0
21
+
22
+ Node-RED >= 3.0.0
23
+
24
+ Nodes Included
25
+ 1) Matter Dynamic Bridge (matter-dynamic-bridge)
26
+
27
+ Creates a Matter bridge and hosts all devices.
28
+
29
+ 2) Matter Device (matter-device)
30
+
31
+ Creates a dynamic Matter endpoint from JSON config.
32
+
33
+ AirNexus behavior (Option 2 – hidden until enabled):
34
+
35
+ Device is created locally, but NOT registered into the bridge/aggregator until enabled
36
+
37
+ Once enabled, the device becomes visible in Alexa/HomeKit/Google
38
+
39
+ Disable sets reachable=false and suppresses outputs (device may remain listed in the controller until you remove it there)
40
+
41
+ 3) Matter Pairing (matter-pairing)
42
+
43
+ Provides commissioning info (QR/manual) and supports “reset pairing” flows.
44
+
45
+ Quick Start
46
+ 1) Create a Bridge
47
+
48
+ Add Matter Dynamic Bridge
49
+
50
+ Configure name/port/interface (default port 5540)
51
+
52
+ Deploy
53
+
54
+ 2) Get Pairing Codes (recommended: use Matter Pairing node)
55
+
56
+ Add Matter Pairing
57
+
58
+ Select your bridge
59
+
60
+ Deploy
61
+
62
+ Inject get to output pairing info (QR code string + manual code)
63
+
64
+ 3) Add Devices
65
+
66
+ Add Matter Device
67
+
68
+ Select the same bridge
69
+
70
+ Set your JSON device config (examples below)
71
+
72
+ Deploy
73
+
74
+ 4) Enable a Device (AirNexus Option 2)
75
+
76
+ Devices will not show up in Alexa until you enable them:
77
+
78
+ Inject to the device:
79
+
80
+ topic: enable
81
+
82
+ payload can include a name
83
+
84
+ Example:
85
+
86
+ {
87
+ "topic": "enable",
88
+ "payload": { "name": "Lounge Thermostat" }
89
+ }
90
+
91
+ Matter Pairing Node
92
+ Inputs (commands)
93
+
94
+ Send an Inject into the Matter Pairing node:
95
+
96
+ Command How to send What it does
97
+ get msg.topic="get" Outputs current pairing info (if commissioned → state=commissioned)
98
+ pair msg.topic="pair" Ensures bridge is online and ready to commission (best effort)
99
+ reset msg.topic="reset" Factory reset commissioning (wipes bridge commissioning storage, regenerates codes)
100
+ unpair / delete msg.topic="unpair" Same as reset
101
+ disable msg.topic="disable" Best-effort stop advertising (takes server offline)
102
+
103
+ Optional payload:
104
+
105
+ { "timeoutMins": 15, "includeSvg": true, "forceReset": true }
106
+
107
+ Output payload (example)
108
+ {
109
+ "bridgeId": "5318704ab571aeeb",
110
+ "pairingEnabled": true,
111
+ "pairingUntil": "2026-01-25T02:30:00.000Z",
112
+ "state": "ready",
113
+ "commissioned": false,
114
+ "qrPairingCode": "MT:....",
115
+ "manualPairingCode": "123-45-678",
116
+ "qrSvg": "<svg>...</svg>"
117
+ }
118
+
119
+ Matter Device Node (AirNexus Option 2: hidden until enabled)
120
+ Enable / Disable / Rename (by payload)
121
+
122
+ Enable
123
+
124
+ msg.topic = "enable";
125
+ msg.payload = { name: "Zone 1 Thermostat" }; // optional
126
+ return msg;
127
+
128
+
129
+ Disable
130
+
131
+ msg.topic = "disable";
132
+ return msg;
133
+
134
+
135
+ Rename
136
+
137
+ msg.topic = "name";
138
+ msg.payload = "New Name Here";
139
+ return msg;
140
+
141
+
142
+ Config combined
143
+
144
+ msg.topic = "config";
145
+ msg.payload = { enabled: true, name: "Zone 2 Thermostat" };
146
+ return msg;
147
+
148
+ State query
149
+ msg.topic = "state";
150
+ return msg;
151
+
152
+ Inputs / Outputs (Matter Device)
153
+
154
+ The Matter Device node has 3 outputs:
155
+
156
+ Output 1 – Events / State
157
+
158
+ $Changed attribute events (when Matter.js emits them)
159
+
160
+ Thermostat writes often appear as interactionEnd snapshot diffs (even when no explicit commands are used)
161
+
162
+ Output 2 – Commands
163
+
164
+ Real Matter cluster commands (e.g. OnOff.on, LevelControl.moveToLevel)
165
+
166
+ Some controllers do attribute writes instead of commands (especially thermostats)
167
+
168
+ Output 3 – Debug / Diagnostics
169
+
170
+ subscription logs, init status, bridgeReset, write retries, sanitization info, etc.
171
+
172
+ Example Output 2 (command)
173
+ {
174
+ "command": "on",
175
+ "cluster": "OnOff",
176
+ "data": {}
177
+ }
178
+
179
+ Example Output 1 (thermostat diff from Alexa)
180
+ {
181
+ "thermostat": {
182
+ "systemMode": 3,
183
+ "occupiedCoolingSetpoint": 2000,
184
+ "occupiedHeatingSetpoint": 2000
185
+ }
186
+ }
187
+
188
+ Command vs Attribute Writes (Thermostats)
189
+
190
+ Many Matter thermostats are controlled via attribute writes rather than explicit commands.
191
+
192
+ Typical behavior:
193
+
194
+ Changing setpoint in Alexa/HomeKit → Output 1 (state diff)
195
+
196
+ Using something like setpointRaiseLower (if controller uses it) → Output 2 (command)
197
+
198
+ Always monitor Output 1 for thermostat changes.
199
+
200
+ Configuration Examples
201
+ Simple On/Off Light
202
+ {
203
+ "deviceType": "OnOffLightDevice"
204
+ }
205
+
206
+ Thermostat (Heating + Cooling + Auto)
207
+ {
208
+ "deviceType": "ThermostatDevice",
209
+ "behaviorFeatures": {
210
+ "Thermostat": ["Heating", "Cooling", "AutoMode"]
211
+ },
212
+ "initialState": {
213
+ "thermostat": {
214
+ "controlSequenceOfOperation": 4,
215
+ "systemMode": 1,
216
+ "localTemperature": 2500,
217
+ "minSetpointDeadBand": 100,
218
+ "occupiedHeatingSetpoint": 2000,
219
+ "occupiedCoolingSetpoint": 2600,
220
+ "minHeatSetpointLimit": 500,
221
+ "maxHeatSetpointLimit": 3500,
222
+ "minCoolSetpointLimit": 500,
223
+ "maxCoolSetpointLimit": 3500
224
+ }
225
+ }
226
+ }
227
+
228
+ Troubleshooting
229
+ Device doesn’t appear in Alexa/HomeKit
230
+
231
+ If you’re using AirNexus Option 2:
232
+
233
+ You must enable the Matter Device node:
234
+
235
+ msg.topic="enable"
236
+
237
+ optionally set name
238
+
239
+ Pairing issues / want to re-pair
240
+
241
+ Use Matter Pairing node:
242
+
243
+ reset to wipe commissioning + generate new QR/manual
244
+
245
+ Commands not appearing in Output 2
246
+
247
+ Expected for devices controlled by attribute writes (thermostats). Use Output 1 diffs.
248
+
249
+ License
250
+
251
+ MIT
252
+
253
+ Acknowledgments
254
+
255
+ Inspired by and based on patterns from node-red-matter-bridge and Matter.js.
256
+
257
+ ## Support
258
+
259
+
260
+ - Node-RED Forum: [Get help from the community](https://discourse.nodered.org)
261
+
262
+ ## Contributing
263
+
264
+ Contributions are welcome! Please feel free to submit a Pull Request.
265
+
266
+ ## License
267
+
268
+ MIT
269
+
270
+ ## Acknowledgments
271
+
272
+ This project was heavily inspired by and based on the excellent work done in [node-red-matter-bridge](https://github.com/sammachin/node-red-matter-bridge) by Sam Machin. The architecture and implementation patterns from that project served as a fundamental guide for developing this dynamic Matter bridge implementation.
273
+
274
+ Built on top of the excellent [Matter.js](https://github.com/project-chip/matter.js) library.
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 115 112">
2
+ <path d="M85.715 52.905c-7.996 2.19-15.164 7.406-19.636 15.152s-5.407 16.568-3.306 24.587l7.835-4.526a23.9 23.9 0 0 1 1.105-11.836l18.309 10.569 4.303-2.487v-4.967L76.016 68.829a23.92 23.92 0 0 1 9.699-6.879zm-57.108 0v9.045a23.91 23.91 0 0 1 9.699 6.879L20 79.398v4.967l4.303 2.487 18.306-10.569c1.39 3.868 1.726 7.938 1.108 11.836l7.832 4.526c2.101-8.02 1.167-16.841-3.306-24.587A32.52 32.52 0 0 0 28.607 52.905zM57.161 20l-4.303 2.484v21.138c-4.046-.731-7.736-2.476-10.804-4.961l-7.838 4.522c5.895 5.83 14 9.429 22.946 9.429s17.051-3.599 22.946-9.429l-7.835-4.522a23.92 23.92 0 0 1-10.807 4.961V22.484z" fill="#000000"/>
3
+ </svg>