Skip to content

Instantly share code, notes, and snippets.

@hi94740
Last active May 9, 2026 11:00
Show Gist options
  • Select an option

  • Save hi94740/b954be984639ff5246db9e69eb9f7622 to your computer and use it in GitHub Desktop.

Select an option

Save hi94740/b954be984639ff5246db9e69eb9f7622 to your computer and use it in GitHub Desktop.
Open VRChat user in Home Assistant. Intended to use with the VRChat integration for Home Assistant.
// ==UserScript==
// @name Open VRChat user in Home Assistant
// @author hi94740
// @version 1.0.0
// @description Intended to use with the VRChat integration for Home Assistant.
// @match https://vrchat.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=vrchat.com
// @require https://cdn.jsdelivr.net/npm/lodash@4.18.1/lodash.min.js
// @connect homeassistant.local
// @connect localhost
// @connect *
// @grant GM.xmlHttpRequest
// ==/UserScript==
try {
;(async () => {
"use strict"
const storageKey = "homeassistant"
const getConfig = defaultValues => {
const valuesJson = localStorage[storageKey]
if (!valuesJson) return defaultValues
try {
return { ...defaultValues, ...JSON.parse(valuesJson) }
} catch {
return defaultValues
}
}
const setConfig = values => {
localStorage[storageKey] = JSON.stringify(values)
}
const config = getConfig({
token: "",
baseUrl: "http://homeassistant.local:8123",
alternativeBaseUrl: "",
openInApp: false
})
const assertPrompt = (message, defaultValue, errorMessage) => {
const value = prompt(message, defaultValue) ?? defaultValue
if (!value) throw new Error(errorMessage)
return value
}
const configure = () => {
config.baseUrl = assertPrompt(
"Home Assistant URL",
config.baseUrl,
"Home Assistant URL is required to link VRChat user to Home Assistant page!"
)
config.alternativeBaseUrl =
prompt("Alternative Home Assistant URL (Optional)", config.alternativeBaseUrl) ??
config.alternativeBaseUrl
config.token = assertPrompt(
"Home Assistant access token",
config.token,
"Home Assistant access token is required to link VRChat user to Home Assistant page!"
)
config.openInApp = confirm("Open in app?")
setConfig(config)
}
if (!config.token) configure()
const createMutationObserver = cb => {
try {
cb()
} catch (e) {
console.warn(e)
} finally {
const observer = new MutationObserver(_.debounce(cb, 500))
observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true
})
return observer
}
}
createMutationObserver(() => {
if (document.querySelector(".ha-settings")) return
const sortByButton = document.querySelector(
'button[aria-label="Expand Options"]'
)?.parentElement
if (sortByButton) {
const haSettingsButton = sortByButton.cloneNode(true)
haSettingsButton.classList.add("ha-settings")
haSettingsButton.style.marginLeft = "8px"
haSettingsButton.querySelector("button").innerText = "⚙ HA"
haSettingsButton.addEventListener("click", () => {
try {
configure()
} catch (e) {
alert(e)
throw e
}
location.reload()
})
sortByButton.after(haSettingsButton)
}
})
const getUserId = s => s.match(/usr_(\w|-)+/)?.[0]
const currentUserId = getUserId(
(
await new Promise(res => {
const observer = createMutationObserver(() => {
const a = document.querySelector("div.leftbar a")
if (a) {
setTimeout(() => observer.disconnect())
res(a)
}
})
})
).href
)
let baseUrl
const userIdMap = await Promise.any(
[config.baseUrl, config.alternativeBaseUrl].map(async u => {
if (!u) throw new Error("Home Assistant URL not provided.")
const res = await GM.xmlHttpRequest({
url: new URL("/api/template", u),
method: "POST",
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json"
},
data: JSON.stringify({
template: `{% set ns = namespace(m={}) %}
{% for id in integration_entities("vrchat") %}
{% if state_attr(id, "friend_of") and state_attr(id, "friend_of") == ${JSON.stringify(currentUserId)}%}
{% set ns.m = combine(ns.m, {state_attr(id, "id"): device_id(id)}) %}
{% endif %}
{% endfor %}
{{ ns.m | to_json }}`
})
})
if (res.status !== 200)
throw new Error("Error fetching user ids from Home Assistant.")
const map = JSON.parse(res.responseText)
if (!baseUrl) baseUrl = u
return map
})
)
const navigateBaseUrl = config.openInApp
? "homeassistant://navigate"
: baseUrl
const deviceUrl = id =>
new URL(`/config/devices/device/${id}`, navigateBaseUrl)
const target = config.openInApp ? "_self" : "_blank"
createMutationObserver(() => {
document
.querySelectorAll('div[aria-label="User Card"]')
.forEach(userCard => {
if (userCard.querySelector(".ha-link")) return
const id = getUserId(userCard.querySelector("a").href)
const el = document.createElement("a")
el.innerText = "HA"
el.className = "ha-link"
el.target = target
el.href = deviceUrl(userIdMap[id])
userCard.querySelector('div[aria-label="User Info"]').after(el)
})
const shareButton = document.querySelector(
'svg[data-icon="share-from-square"]'
)?.parentElement?.parentElement
if (shareButton) {
const haButton = shareButton.cloneNode(true)
haButton.querySelector("button").innerText = "HA"
haButton.addEventListener("click", () =>
open(deviceUrl(userIdMap[getUserId(location.pathname)]), target)
)
shareButton.after(haButton)
shareButton.remove()
}
})
})()
} catch (e) {
alert(e)
throw e
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment