diff --git a/.gitignore b/.gitignore index dea3b97..ca2230d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist **/.env **/node_modules NOTES.md -.docker-test \ No newline at end of file +.docker-test +options.dev.json diff --git a/ps5-mqtt/run.sh b/ps5-mqtt/run.sh index 82e6d3a..230ad71 100644 --- a/ps5-mqtt/run.sh +++ b/ps5-mqtt/run.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bashio +export CONFIG_PATH="/data/options.json" +export CREDENTIAL_STORAGE_PATH="/config/ps5-mqtt/credentials.json" + if bashio::config.is_empty 'mqtt' && bashio::var.has_value "$(bashio::services 'mqtt')"; then export MQTT_HOST="$(bashio::services 'mqtt' 'host')" export MQTT_PORT="$(bashio::services 'mqtt' 'port')" @@ -12,35 +15,12 @@ else export MQTT_PASSWORD=$(bashio::config 'mqtt.pass') fi -export DEVICE_CHECK_INTERVAL=$(bashio::config 'device_check_interval') -export DEVICE_DISCOVERY_INTERVAL=$(bashio::config 'device_discovery_interval') -export ACCOUNT_CHECK_INTERVAL=$(bashio::config 'account_check_interval') - -export INCLUDE_PS4_DEVICES=$(bashio::config 'include_ps4_devices') - export FRONTEND_PORT=8645 if [ ! -z $(bashio::addon.ingress_port) ]; then FRONTEND_PORT=$(bashio::addon.ingress_port) fi -if [ ! -z $(bashio::config 'psn_accounts') ]; then - export PSN_ACCOUNTS="[" - - for computer in $(bashio::config 'psn_accounts|keys'); do - USERNAME=$(bashio::config "psn_accounts[${computer}].username") - NPSSO=$(bashio::config "psn_accounts[${computer}].npsso") - - PSN_ACCOUNTS+="{\"username\":\"$USERNAME\",\"npsso\":\"$NPSSO\"}," - done - - PSN_ACCOUNTS=${PSN_ACCOUNTS::-1} - PSN_ACCOUNTS+="]" -else - export PSN_ACCOUNTS="[]" -fi - -export CREDENTIAL_STORAGE_PATH="/config/ps5-mqtt/credentials.json" - +# configure logger export DEBUG="*,-mqttjs*,-mqtt-packet*,-playactor:*,-@ha:state*,-@ha:ps5:poll*,-@ha:ps5:check*" if [ ! -z $(bashio::config 'logger') ]; then diff --git a/ps5-mqtt/server/src/config.ts b/ps5-mqtt/server/src/config.ts new file mode 100644 index 0000000..792eb30 --- /dev/null +++ b/ps5-mqtt/server/src/config.ts @@ -0,0 +1,112 @@ +import * as fs from 'fs'; +import * as process from 'process'; +import lodash from 'lodash'; + +import { createErrorLogger } from './util/error-logger'; + +const logError = createErrorLogger(); + +export interface AppConfig { + // yml options + mqtt: { + host: string, + pass: string, + port: string, + user: string + }, + + device_check_interval: number, + device_discovery_interval: number, + + include_ps4_devices: boolean, + + psn_accounts: AppConfig.PsnAccountInfo[], + + account_check_interval: number, + + // non yml options + credentialsStoragePath: string, + frontendPort: string, +} + +export module AppConfig { + export interface PsnAccountInfo { + npsso: string, + username?: string, + } +} + +export function getAppConfig(): AppConfig { + const { + CONFIG_PATH + } = process.env; + + const configFileOptions = getJsonConfig(CONFIG_PATH); + const envOptions = getEnvConfig(); + + return lodash.merge(configFileOptions, envOptions) as AppConfig +} + +function getJsonConfig(configPath: string): Partial { + if (!fs.existsSync(configPath)) { + logError(`config could not be read from '${configPath}'`); + return {}; + } + const optionsRaw = fs.readFileSync(configPath, { encoding: 'utf-8' }); + try { + const options: AppConfig = JSON.parse(optionsRaw); + return options; + } catch (err) { + logError(`Received invalid options: "${optionsRaw}".`) + return {}; + } +} + +function getEnvConfig(): Partial { + const { + MQTT_HOST, + MQTT_PASSWORD, + MQTT_PORT, + MQTT_USERNAME, + + FRONTEND_PORT, + + CREDENTIAL_STORAGE_PATH, + + INCLUDE_PS4_DEVICES, + + DEVICE_CHECK_INTERVAL, + DEVICE_DISCOVERY_INTERVAL, + ACCOUNT_CHECK_INTERVAL, + + PSN_ACCOUNTS, + } = process.env; + + return { + mqtt: { + host: MQTT_HOST, + port: MQTT_PORT, + pass: MQTT_PASSWORD, + user: MQTT_USERNAME, + }, + + device_check_interval: + DEVICE_CHECK_INTERVAL + ? parseInt(DEVICE_CHECK_INTERVAL, 10) + : undefined, + device_discovery_interval: + DEVICE_DISCOVERY_INTERVAL + ? parseInt(DEVICE_DISCOVERY_INTERVAL, 10) + : undefined, + account_check_interval: + ACCOUNT_CHECK_INTERVAL + ? parseInt(ACCOUNT_CHECK_INTERVAL, 10) + : undefined, + + psn_accounts: PSN_ACCOUNTS ? JSON.parse(PSN_ACCOUNTS) : undefined, + include_ps4_devices: Boolean(INCLUDE_PS4_DEVICES), + + credentialsStoragePath: CREDENTIAL_STORAGE_PATH, + frontendPort: FRONTEND_PORT + } +} \ No newline at end of file diff --git a/ps5-mqtt/server/src/index.ts b/ps5-mqtt/server/src/index.ts index 0990d7a..ee6f66f 100644 --- a/ps5-mqtt/server/src/index.ts +++ b/ps5-mqtt/server/src/index.ts @@ -5,6 +5,7 @@ import createDebugger from "debug"; import os from 'os'; import path from 'path'; import createSagaMiddleware from "redux-saga"; +import { AppConfig, getAppConfig } from "./config"; import { PsnAccount } from "./psn-account"; import reducer, { getDeviceRegistry, @@ -22,47 +23,24 @@ const debugMqtt = createDebugger("@ha:ps5:mqtt"); const debugState = createDebugger("@ha:state"); const logError = createErrorLogger(); -const { - MQTT_HOST, - MQTT_PASSWORD, - MQTT_PORT, - MQTT_USERNAME, - - FRONTEND_PORT, - - CREDENTIAL_STORAGE_PATH, - - INCLUDE_PS4_DEVICES, - - DEVICE_CHECK_INTERVAL, - DEVICE_DISCOVERY_INTERVAL, - ACCOUNT_CHECK_INTERVAL, - - PSN_ACCOUNTS, -} = process.env; - -const accountsInfo = JSON.parse(PSN_ACCOUNTS); - -const credentialStoragePath = CREDENTIAL_STORAGE_PATH - ? CREDENTIAL_STORAGE_PATH - : path.join(os.homedir(), '.config', 'playactor', 'credentials.json'); +const appConfig = getAppConfig(); const createMqtt = async (): Promise => { - return await MQTT.connectAsync(`mqtt://${MQTT_HOST}`, { - password: MQTT_PASSWORD, - port: parseInt(MQTT_PORT || "1883", 10), - username: MQTT_USERNAME, + return await MQTT.connectAsync(`mqtt://${appConfig.mqtt.host}`, { + password: appConfig.mqtt.pass, + port: parseInt(appConfig.mqtt.port || "1883", 10), + username: appConfig.mqtt.user, reconnectPeriod: 2000, connectTimeout: 3 * 60 * 1000 // 3 minutes }); }; async function getPsnAccountRegistry( - accounts: { npsso: string }[] + accounts: AppConfig.PsnAccountInfo[] ): Promise> { const accountRegistry: Record = {}; - for (const { npsso } of accounts) { - const account = await PsnAccount.exchangeNpssoForPsnAccount(npsso); + for (const { npsso, username } of accounts) { + const account = await PsnAccount.exchangeNpssoForPsnAccount(npsso, username); accountRegistry[account.accountId] = account; } return accountRegistry; @@ -77,15 +55,13 @@ async function run() { const settings: Settings = { // polling intervals - checkDevicesInterval: - parseInt(DEVICE_CHECK_INTERVAL || "5000", 10), - checkAccountInterval: - parseInt(ACCOUNT_CHECK_INTERVAL || "5000", 10), - discoverDevicesInterval: - parseInt(DEVICE_DISCOVERY_INTERVAL || "60000", 10), - - credentialStoragePath, - allowPs4Devices: INCLUDE_PS4_DEVICES === 'true', + checkDevicesInterval: appConfig.device_check_interval || 5000, + checkAccountInterval: appConfig.account_check_interval || 5000, + discoverDevicesInterval: appConfig.device_discovery_interval || 60000, + + credentialStoragePath: appConfig.credentialsStoragePath + ?? path.join(os.homedir(), '.config', 'playactor', 'credentials.json'), + allowPs4Devices: appConfig.include_ps4_devices ?? true, }; try { @@ -95,13 +71,13 @@ async function run() { [SETTINGS]: settings, } }); - const accounts = await getPsnAccountRegistry(accountsInfo) + const accounts = await getPsnAccountRegistry(appConfig.psn_accounts ?? []) const store = configureStore({ reducer, middleware: [sagaMiddleware], preloadedState: { devices: {}, - accounts: accounts, + accounts: accounts, } }); store.subscribe(() => { @@ -135,14 +111,14 @@ async function run() { if (Object.keys(accounts).length > 0) { store.dispatch(pollPsnPresence()); } - + store.dispatch(pollDiscovery()); store.dispatch(pollDevices()); } catch (e) { logError(e); } - setupWebserver(FRONTEND_PORT ?? 3000, settings) + setupWebserver(appConfig.frontendPort ?? 3000, settings) } if (require.main === module) { diff --git a/ps5-mqtt/server/src/psn-account.ts b/ps5-mqtt/server/src/psn-account.ts index 277ff96..ac68541 100644 --- a/ps5-mqtt/server/src/psn-account.ts +++ b/ps5-mqtt/server/src/psn-account.ts @@ -25,8 +25,8 @@ export module PsnAccount { launchPlatform: NormalizedDeviceType; } - export async function exchangeNpssoForPsnAccount(npsso: string): Promise { - return getAccount(npsso); + export async function exchangeNpssoForPsnAccount(npsso: string, username?: string): Promise { + return getAccount(npsso, username); } export async function updateAccount(account: PsnAccount): Promise { @@ -86,7 +86,7 @@ interface BasicPresenceResponse { } } -async function getAccount(npsso: string): Promise { +async function getAccount(npsso: string, username?: string): Promise { const accessCode = await psnApi.exchangeNpssoForCode(npsso); const authorization = await psnApi.exchangeCodeForAccessToken(accessCode); @@ -94,7 +94,7 @@ async function getAccount(npsso: string): Promise { const { profile } = await psnApi.getProfileFromUserName(authorization, 'me'); const account: PsnAccount = { - accountName: profile.onlineId, + accountName: username ?? profile.onlineId, accountId: profile.accountId, npsso, authInfo: convertAuthResponseToAuthInfo(authorization) @@ -130,7 +130,7 @@ async function getAccountActivity({ accountId, authInfo }: PsnAccount): Promise< launchPlatform: activeTitle.launchPlatform.toUpperCase() as NormalizedDeviceType, } } else { - if(response.status >= 400 && response.status < 600) { + if (response.status >= 400 && response.status < 600) { debug(`Unable to retrieve PSN information. API response: "${response.status}:${response.statusText}"`) } return undefined; diff --git a/ps5-mqtt/server/src/redux/reducer.ts b/ps5-mqtt/server/src/redux/reducer.ts index d69ce52..ea7f9ce 100644 --- a/ps5-mqtt/server/src/redux/reducer.ts +++ b/ps5-mqtt/server/src/redux/reducer.ts @@ -1,4 +1,4 @@ -import { merge } from "lodash"; +import _ from "lodash"; import type { AnyAction, State } from "./types"; const defaultState: State = { @@ -9,7 +9,7 @@ const defaultState: State = { const reducer = (state = defaultState, action: AnyAction) => { switch (action.type) { case "ADD_DEVICE": { - return merge({}, state, { + return _.merge({}, state, { devices: { [action.payload.id]: action.payload, }, @@ -17,13 +17,13 @@ const reducer = (state = defaultState, action: AnyAction) => { } case "UPDATE_HOME_ASSISTANT": { - const newState = merge({}, state); + const newState = _.merge({}, state); newState.devices[action.payload.id] = action.payload; return newState; } case "TRANSITIONING": { - return merge({}, state, { + return _.merge({}, state, { devices: { [action.payload.id]: { transitioning: action.payload.transitioning, @@ -33,7 +33,7 @@ const reducer = (state = defaultState, action: AnyAction) => { } case "UPDATE_PSN_ACCOUNT": { - return merge({}, state, { + return _.merge({}, state, { accounts: { [action.payload.accountId]: action.payload, }, diff --git a/ps5-mqtt/server/src/redux/sagas/check-psn-presence.ts b/ps5-mqtt/server/src/redux/sagas/check-psn-presence.ts index cf07d49..64dc071 100644 --- a/ps5-mqtt/server/src/redux/sagas/check-psn-presence.ts +++ b/ps5-mqtt/server/src/redux/sagas/check-psn-presence.ts @@ -1,4 +1,3 @@ -import { merge } from "lodash"; import { call, put, select } from "redux-saga/effects"; import { PsnAccount } from "../../psn-account"; import { createErrorLogger } from "../../util/error-logger"; diff --git a/ps5-mqtt/server/src/redux/sagas/delay-for-transition.ts b/ps5-mqtt/server/src/redux/sagas/delay-for-transition.ts index 5c56601..bd4ef56 100644 --- a/ps5-mqtt/server/src/redux/sagas/delay-for-transition.ts +++ b/ps5-mqtt/server/src/redux/sagas/delay-for-transition.ts @@ -1,5 +1,5 @@ import createDebugger from "debug" -import { merge } from "lodash" +import lodash from "lodash" import { delay, put } from "redux-saga/effects" import { pollDevices, pollPsnPresence, setTransitioning } from "../action-creators" import type { SetTransitioningAction } from "../types" @@ -11,7 +11,7 @@ function* delayForTransition(action: SetTransitioningAction) { yield delay(15000) debug("Resume polling") yield put( - setTransitioning(merge({}, action.payload, { transitioning: false })) + setTransitioning(lodash.merge({}, action.payload, { transitioning: false })) ); } else { yield put(pollDevices()); diff --git a/ps5-mqtt/server/src/redux/sagas/discover-devices.ts b/ps5-mqtt/server/src/redux/sagas/discover-devices.ts index f0f71d6..ce3b4c6 100644 --- a/ps5-mqtt/server/src/redux/sagas/discover-devices.ts +++ b/ps5-mqtt/server/src/redux/sagas/discover-devices.ts @@ -1,4 +1,3 @@ -import { merge } from "lodash"; import { Discovery } from "playactor/dist/discovery"; import { DeviceType } from "playactor/dist/discovery/model"; import { call, getContext, put, select } from "redux-saga/effects"; @@ -28,7 +27,7 @@ const useAsyncIterableWithSaga = function* discoverDevices() { const { allowPs4Devices }: Settings = yield getContext(SETTINGS); - + const discovery = new Discovery(); let discoveredDevices: Device[] = yield call( useAsyncIterableWithSaga( @@ -40,7 +39,7 @@ function* discoverDevices() { ) ); - if(!allowPs4Devices) { + if (!allowPs4Devices) { discoveredDevices = discoveredDevices.filter(d => d.type === DeviceType.PS5); } @@ -50,11 +49,11 @@ function* discoverDevices() { yield put( registerDevice({ ...device, - available: true, - normalizedName: + available: true, + normalizedName: device.name.replace(/[^a-zA-Z\d\s-_:]/g, '') - .replace(/[\s-]/g, '_') - .toLowerCase(), + .replace(/[\s-]/g, '_') + .toLowerCase(), activity: undefined, }) ); diff --git a/ps5-mqtt/server/src/redux/sagas/turn-off-device.ts b/ps5-mqtt/server/src/redux/sagas/turn-off-device.ts index 603f760..762a1b4 100644 --- a/ps5-mqtt/server/src/redux/sagas/turn-off-device.ts +++ b/ps5-mqtt/server/src/redux/sagas/turn-off-device.ts @@ -1,25 +1,25 @@ import createDebugger from "debug"; -import { merge } from "lodash"; +import lodash from "lodash"; import { getContext, put } from "redux-saga/effects"; import sh from "shelljs"; import { Settings, SETTINGS } from "../../services"; import { createErrorLogger } from "../../util/error-logger"; import { setTransitioning, updateHomeAssistant } from "../action-creators"; -import type { ChangePowerModeAction, Device } from "../types"; +import type { ChangePowerModeAction } from "../types"; const debug = createDebugger("@ha:ps5:turnOffDevice"); const debugError = createErrorLogger(); function* turnOffDevice(action: ChangePowerModeAction) { const { credentialStoragePath }: Settings = yield getContext(SETTINGS); - + if (action.payload.mode !== 'STANDBY') { return; } yield put( setTransitioning( - merge({}, action.payload.device, { transitioning: true }) + lodash.merge({}, action.payload.device, { transitioning: true }) ) ); try { @@ -37,8 +37,8 @@ function* turnOffDevice(action: ChangePowerModeAction) { yield put( updateHomeAssistant({ - ...action.payload.device, - status: "STANDBY", + ...action.payload.device, + status: "STANDBY", activity: undefined // also clear the activity when a device turns off }) ); diff --git a/ps5-mqtt/server/src/redux/sagas/turn-on-device.ts b/ps5-mqtt/server/src/redux/sagas/turn-on-device.ts index c4d10a8..5e0f796 100644 --- a/ps5-mqtt/server/src/redux/sagas/turn-on-device.ts +++ b/ps5-mqtt/server/src/redux/sagas/turn-on-device.ts @@ -1,5 +1,5 @@ import createDebugger from "debug"; -import { merge } from "lodash"; +import lodash from "lodash"; import { getContext, put } from "redux-saga/effects"; import sh from "shelljs"; import { Settings, SETTINGS } from "../../services"; @@ -12,14 +12,14 @@ const debugError = createErrorLogger(); function* turnOnDevice(action: ChangePowerModeAction) { const { credentialStoragePath }: Settings = yield getContext(SETTINGS); - + if (action.payload.mode !== 'AWAKE') { return; } yield put( setTransitioning( - merge({}, action.payload.device, { transitioning: true }) + lodash.merge({}, action.payload.device, { transitioning: true }) ) ); try { @@ -37,7 +37,7 @@ function* turnOnDevice(action: ChangePowerModeAction) { yield put( updateHomeAssistant({ - ...action.payload.device, + ...action.payload.device, status: "AWAKE" }) ); diff --git a/ps5-mqtt/server/src/redux/sagas/update-account.ts b/ps5-mqtt/server/src/redux/sagas/update-account.ts index d76dd7e..f30ebfb 100644 --- a/ps5-mqtt/server/src/redux/sagas/update-account.ts +++ b/ps5-mqtt/server/src/redux/sagas/update-account.ts @@ -1,4 +1,4 @@ -import { cloneDeep, isEqual } from "lodash"; +import lodash from "lodash"; import { put, select } from "redux-saga/effects"; import { updateHomeAssistant } from "../action-creators"; import { getDeviceList } from "../selectors"; @@ -10,9 +10,9 @@ function* updateAccount({ payload: account }: UpdateAccountAction) { const devices: Device[] = yield select(getDeviceList); for (const device of devices) { - const clonedDeviceState: Device = cloneDeep(device); + const clonedDeviceState: Device = lodash.cloneDeep(device); - const isAccountCurrentlyActiveOnDevice = + const isAccountCurrentlyActiveOnDevice = clonedDeviceState.activity?.activePlayers.some(p => p === account.accountName); // the player is using an app that matches the current device's platform @@ -29,7 +29,7 @@ function* updateAccount({ payload: account }: UpdateAccountAction) { else if (isAccountCurrentlyActiveOnDevice && account.activity === undefined) { if (clonedDeviceState.activity.activePlayers.length > 1) { // remove current player from the player list - clonedDeviceState.activity.activePlayers = + clonedDeviceState.activity.activePlayers = clonedDeviceState.activity.activePlayers.filter(p => p !== account.accountName); } else { // no players = no activity @@ -38,7 +38,7 @@ function* updateAccount({ payload: account }: UpdateAccountAction) { } // only apply update if something actually changed - if (!isEqual(device, clonedDeviceState)) { + if (!lodash.isEqual(device, clonedDeviceState)) { yield put(updateHomeAssistant(clonedDeviceState)); } }