diff --git a/app.ts b/app.mts similarity index 81% rename from app.ts rename to app.mts index 4582ac7..560f72c 100644 --- a/app.ts +++ b/app.mts @@ -1,36 +1,36 @@ import Homey from "homey"; +import type { YandexMediaDevice } from "./library/client/home/devices/media.js"; import { Yandex } from "./library/index.js"; -import { YandexSpeaker } from "./library/client/home/devices/speaker.js"; -module.exports = class YandexApp extends Homey.App { +export default class YandexApp extends Homey.App { yandex!: Yandex; - private scenarioIcons!: string[]; + #scenarioIcons!: string[]; async onInit() { this.yandex = new Yandex({ get: async () => JSON.parse(this.homey.settings.get("storage") ?? "{}"), set: async content => this.homey.settings.set("storage", JSON.stringify(content)) }); - await this.initFlows(); - setInterval(async () => await this.yandex.api.quasar.getDevices().catch(console.error), 2.16e+7); + + await this.initFlows(); } async onUninit() { - await this.yandex.home.destroy(); + await this.yandex.home.disconnect(); } async initFlows() { const mediaSayAction = this.homey.flow.getActionCard("media_say"); mediaSayAction.registerRunListener(async args => { - const speaker: YandexSpeaker = await args.device.getSpeaker(); - await speaker.actionSay(args.text, args.volume); + const speaker: YandexMediaDevice = await args.device.getSpeaker(); + await speaker.say(args.text, args.volume); }); const mediaRunAction = this.homey.flow.getActionCard("media_run"); mediaRunAction.registerRunListener(async args => { - const speaker: YandexSpeaker = await args.device.getSpeaker(); - const response = await speaker.actionRun(args.command, args.volume) || ""; + const speaker: YandexMediaDevice = await args.device.getSpeaker(); + const response = await speaker.send(args.command, args.volume); return { response }; }); @@ -42,15 +42,15 @@ module.exports = class YandexApp extends Homey.App { const items = []; // Обновление иконки для нового сценария - if (!query.length && this.scenarioIcons === undefined) { + if (!query.length && this.#scenarioIcons === undefined) { const scenarioIcons = await this.yandex.api.quasar.getScenarioIcons(); - this.scenarioIcons = scenarioIcons.icons; + this.#scenarioIcons = scenarioIcons.icons; } // Отображение нового сценария if (query.length && !names.includes(query)) { - const index = Math.floor(Math.random() * this.scenarioIcons.length); - const icon = this.scenarioIcons[index]; + const index = Math.floor(Math.random() * this.#scenarioIcons.length); + const icon = this.#scenarioIcons[index]; items.push({ name: query, @@ -115,7 +115,7 @@ module.exports = class YandexApp extends Homey.App { type: "devices.capabilities.quasar.server_action", state: { instance: "text_action", - value: "громче на 0" + "!".repeat(this.getNextNumber(scenarioActions)) + value: `громче на 0${"!".repeat(this.getNextNumber(scenarioActions))}` }, parameters: { instance: "text_action" @@ -131,9 +131,9 @@ module.exports = class YandexApp extends Homey.App { } private getNextNumber(nums: number[]) { - nums = nums.sort(); - for (let n = 1; n <= nums.length + 1; n++) - if (nums.indexOf(n) === -1) return n; + const sorted = nums.sort(); + for (let n = 1; n <= sorted.length + 1; n++) + if (sorted.indexOf(n) === -1) return n; return 1; }; } \ No newline at end of file diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000..65beab0 --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "formatter": { + "lineWidth": 120 + }, + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "warn" + } + } + } +} \ No newline at end of file diff --git a/drivers/Device.ts b/drivers/Device.mts similarity index 52% rename from drivers/Device.ts rename to drivers/Device.mts index 0048d1e..efd550a 100644 --- a/drivers/Device.ts +++ b/drivers/Device.mts @@ -1,175 +1,173 @@ import Homey from "homey"; -import { Yandex } from "../library/index.js"; -import { YandexSpeaker } from "../library/client/home/devices/speaker.js"; -import { YandexSpeakerState } from "../library/client/home/typings.js"; +import type YandexApp from "../app.mjs"; +import { AliceState, ControlAction, type RepeatMode, type YandexMediaDevice } from "../library/client/home/devices/media.js"; +import type { Yandex } from "../library/index.js"; +import type * as Types from "../library/typings/index.js"; export default class Device extends Homey.Device { - private id!: string; - private yandex!: Yandex; - private speaker?: YandexSpeaker; - private image!: Homey.Image; - - private userId!: string; - private waitings: Record = {}; - private aliceActive: boolean = false; - - private lastTrackId: string = ""; - private lastTrackAlbumId: string = ""; - private lastTrackImage: string = "https://"; - private lastTrackLyrics: Array = []; - private lastTrackLyricsTimeout?: NodeJS.Timeout; + #id!: string; + #yandex!: Yandex; + #speaker?: YandexMediaDevice; + #image!: Homey.Image; + + #userId!: string; + #waitings: Record = {}; + #aliceActive = false; + + #lastTrackId = ""; + #lastTrackAlbumId = ""; + #lastTrackImage = "https://"; + #lastTrackLyrics: Array = []; + #lastTrackLyricsTimeout?: NodeJS.Timeout; async onInit() { - const data = this.getData(); - this.id = data.id as string; - this.yandex = (this.homey.app as any).yandex; + this.#id = (this.getData()).id as string; + this.#yandex = (this.homey.app as YandexApp).yandex; // Значение по умолчанию при создании колонки this.getCapabilityValue("speaker_playing") ?? await this.setCapabilityValue("speaker_playing", false); // Создание и установка обложки - this.image = await this.homey.images.createImage(); - await this.setAlbumArtImage(this.image); + this.#image = await this.homey.images.createImage(); + await this.setAlbumArtImage(this.#image); // Регистрация свойств this.registerCapabilities(); // Получение идентификатора пользователя - const accountStatus = await this.yandex.api.music.getAccountStatus(); - this.userId = String(accountStatus.account.uid); + const accountStatus = await this.#yandex.api.music.getAccountStatus(); + this.#userId = String(accountStatus.account.uid); await this.getSpeaker().catch(this.error); } async onDeleted() { - if (!this.speaker) return; - await this.speaker.destroy(); + if (!this.#speaker) return; + await this.#speaker.disconnect(); } async getSpeaker() { - if (!this.speaker) { - this.speaker = await this.yandex.home.createSpeaker(this.id); - this.speaker.state.volume = this.getCapabilityValue("volume_set") ?? 0; - this.speaker.state.playing = this.getCapabilityValue("speaker_playing"); - this.speaker.on("state", this.handleState); - await this.speaker.connect(); + if (!this.#speaker) { + this.#speaker = await this.#yandex.home.createMediaDevice(this.#id); + this.#speaker.state.volume = this.getCapabilityValue("volume_set") ?? 0; + this.#speaker.state.playing = this.getCapabilityValue("speaker_playing"); + this.#speaker.on("state", this.handleState); + await this.#speaker.connect(); } - return this.speaker; + return this.#speaker; } private registerCapabilities() { this.registerCapabilityListener("speaker_playing", async value => { - this.waitings["speaker_playing"] = Date.now(); + this.#waitings.speaker_playing = Date.now(); const speaker = await this.getSpeaker(); - if (value) await speaker.mediaPlay(); - else await speaker.mediaPause(); + await (value ? speaker.play() : speaker.pause()); }); this.registerCapabilityListener("volume_set", async value => { - this.waitings["volume_set"] = Date.now(); + this.#waitings.volume_set = Date.now(); const speaker = await this.getSpeaker(); await speaker.volumeSet(value); }); this.registerCapabilityListener("speaker_next", async () => { const speaker = await this.getSpeaker(); - await speaker.mediaNext(); + await speaker.next(); }); this.registerCapabilityListener("speaker_prev", async () => { const speaker = await this.getSpeaker(); - await speaker.mediaPrev(); + await speaker.prev(); }); this.registerCapabilityListener("speaker_shuffle", async value => { - this.waitings["speaker_shuffle"] = Date.now(); + this.#waitings.speaker_shuffle = Date.now(); const speaker = await this.getSpeaker(); - await speaker.musicShuffle(value); + await speaker.shuffle(value); }); - this.registerCapabilityListener("speaker_repeat", async value => { - const modes = { none: "none", track: "one", playlist: "all" } as any; - const mode = modes[value]; - - this.waitings["speaker_repeat"] = Date.now(); + this.registerCapabilityListener("speaker_repeat", async (value: "none" | "track" | "playlist") => { + this.#waitings.speaker_repeat = Date.now(); + const modes: Record = { none: 1, track: 2, playlist: 3 }; const speaker = await this.getSpeaker(); - await speaker.musicRepeat(mode); + await speaker.repeat(modes[value]); }); this.registerCapabilityListener("media_rewind", async value => { - this.waitings["media_rewind"] = Date.now(); + this.#waitings.media_rewind = Date.now(); const speaker = await this.getSpeaker(); - await speaker.mediaRewind(value); + await speaker.rewind(value); }); this.registerCapabilityListener("media_lyrics", async value => { const speaker = await this.getSpeaker(); - await speaker.mediaRewind(Number(value)); - clearTimeout(this.lastTrackLyricsTimeout); - this.lastTrackLyricsTimeout = undefined; + await speaker.rewind(Number(value)); + await speaker.play(); + clearTimeout(this.#lastTrackLyricsTimeout); + this.#lastTrackLyricsTimeout = undefined; }); this.hasCapability("media_like") && this.registerCapabilityListener("media_like", async value => { - if (!this.lastTrackId || !this.lastTrackAlbumId) return; + if (!this.#lastTrackId || !this.#lastTrackAlbumId) return; if (value) { - await this.yandex.api.music.addLike(this.userId, this.lastTrackId, this.lastTrackAlbumId); + await this.#yandex.api.music.addLike(this.#userId, this.#lastTrackId, this.#lastTrackAlbumId); return await this.setCapabilityValue("media_dislike", false); } - await this.yandex.api.music.removeLike(this.userId, this.lastTrackId); + await this.#yandex.api.music.removeLike(this.#userId, this.#lastTrackId); }); this.hasCapability("media_dislike") && this.registerCapabilityListener("media_dislike", async value => { - if (!this.lastTrackId || !this.lastTrackAlbumId) return; + if (!this.#lastTrackId || !this.#lastTrackAlbumId) return; if (value) { - await this.yandex.api.music.addDislike(this.userId, this.lastTrackId, this.lastTrackAlbumId); + await this.#yandex.api.music.addDislike(this.#userId, this.#lastTrackId, this.#lastTrackAlbumId); return await this.setCapabilityValue("media_like", false); } - await this.yandex.api.music.removeDislike(this.userId, this.lastTrackId); + await this.#yandex.api.music.removeDislike(this.#userId, this.#lastTrackId); }); this.hasCapability("media_power") && this.registerCapabilityListener("media_power", async value => { const speaker = await this.getSpeaker(); - await speaker.controlPower(value); + await speaker.power(value); }); this.hasCapability("media_home") && this.registerCapabilityListener("media_home", async () => { const speaker = await this.getSpeaker(); - await speaker.controlHome(); + await speaker.home(); }); this.hasCapability("media_left") && this.registerCapabilityListener("media_left", async () => { const speaker = await this.getSpeaker(); - await speaker.controlLeft(); + await speaker.control(ControlAction.Left); }); this.hasCapability("media_right") && this.registerCapabilityListener("media_right", async () => { const speaker = await this.getSpeaker(); - await speaker.controlRight(); + await speaker.control(ControlAction.Right); }); this.hasCapability("media_up") && this.registerCapabilityListener("media_up", async () => { const speaker = await this.getSpeaker(); - await speaker.controlUp(); + await speaker.control(ControlAction.Up); }); this.hasCapability("media_down") && this.registerCapabilityListener("media_down", async () => { const speaker = await this.getSpeaker(); - await speaker.controlDown(); + await speaker.control(ControlAction.Down); }); this.hasCapability("media_back") && this.registerCapabilityListener("media_back", async () => { const speaker = await this.getSpeaker(); - await speaker.controlBack(); + await speaker.back(); }); this.hasCapability("media_click") && this.registerCapabilityListener("media_click", async () => { const speaker = await this.getSpeaker(); - await speaker.controlClick(); + await speaker.control(ControlAction.Click); }); } - private handleState = async (state: Partial) => { + private handleState = async (state: Types.GlagolState) => { const capabilities: Record = { "speaker_playing": state.playing || false, "speaker_shuffle": state.playerState?.entityInfo?.shuffled || false, @@ -190,24 +188,24 @@ export default class Device extends Homey.Device { // Для нового трека const trackId = state.playerState?.id || ""; - if (trackId !== this.lastTrackId) { - const track = await this.yandex.api.music.getTrack(trackId).catch(() => undefined); - this.lastTrackId = track?.id || trackId; - this.lastTrackAlbumId = track?.albums?.[0]?.id || ""; - this.lastTrackLyrics = []; + if (trackId !== this.#lastTrackId) { + const track = await this.#yandex.api.music.getTrack(trackId).catch(() => undefined); + this.#lastTrackId = track?.id || trackId; + this.#lastTrackAlbumId = track?.albums?.[0]?.id || ""; + this.#lastTrackLyrics = []; const [likes, dislikes] = await Promise.all([ - this.yandex.api.music.getLikes(this.userId).catch(() => [] as any[]), - this.yandex.api.music.getDislikes(this.userId).catch(() => [] as any[]), + this.#yandex.api.music.getLikes(this.#userId).catch(() => [] as any[]), + this.#yandex.api.music.getDislikes(this.#userId).catch(() => [] as any[]), this.updateTrackCover(state, track), this.updateTrackLyrics(state, track) ]); - capabilities["speaker_track"] = track?.title || state.playerState?.title || ""; - capabilities["speaker_artist"] = track?.artists?.map((a: any) => a.name)?.join(", ") || state.playerState?.subtitle || ""; - capabilities["speaker_album"] = track?.albums?.[0]?.title || state.playerState?.playlistId || ""; - capabilities["media_like"] = likes.find(like => like.id === track?.id) ? true : false; - capabilities["media_dislike"] = dislikes.find(dislike => dislike.id === track?.id) ? true : false; + capabilities.speaker_track = track?.title || state.playerState?.title || ""; + capabilities.speaker_artist = track?.artists?.map((a: any) => a.name)?.join(", ") || state.playerState?.subtitle || ""; + capabilities.speaker_album = track?.albums?.[0]?.title || state.playerState?.playlistId || ""; + capabilities.media_like = !!likes.find(like => like.id === track?.id); + capabilities.media_dislike = !!dislikes.find(dislike => dislike.id === track?.id); } await this.handleLyricsSync(state); @@ -215,9 +213,9 @@ export default class Device extends Homey.Device { await Promise.all( Object.entries(capabilities).map(async ([capability, value]) => { - if (Object.keys(this.waitings).includes(capability)) { - if (Date.now() - this.waitings[capability] >= 3000) - delete this.waitings[capability]; + if (Object.keys(this.#waitings).includes(capability)) { + if (Date.now() - this.#waitings[capability] >= 3000) + delete this.#waitings[capability]; return Promise.resolve(); } @@ -228,21 +226,22 @@ export default class Device extends Homey.Device { ); }; - private async updateTrackCover(state: Partial, track?: any) { + private async updateTrackCover(state: Types.GlagolState, track?: any) { const trackImage = track?.coverUri || track?.ogImage || state.playerState?.extra?.coverURI || ""; const imageQuality = this.getSetting("image_quality") || 500; - - this.lastTrackImage = "https://" + trackImage.replace("%%", `${imageQuality}x${imageQuality}`); - this.image.setUrl(this.lastTrackImage); - await this.image.update(); + + this.#lastTrackImage = `https://${trackImage.replace("%%", `${imageQuality}x${imageQuality}`)}`; + + this.#image.setUrl(this.#lastTrackImage); + await this.#image.update(); } - private async updateTrackLyrics(state: Partial, track?: any) { + private async updateTrackLyrics(state: Types.GlagolState, track?: any) { const trackId = state.playerState?.id || track?.id; let lyricsValues = [{ id: "none", title: "Нет текста песни" }]; if (track?.lyricsInfo?.hasAvailableSyncLyrics) { - const lyrics = await this.yandex.api.music.getLyrics(trackId).catch(() => ""); + const lyrics = await this.#yandex.api.music.getLyrics(trackId).catch(() => ""); const lyricsLines = lyrics.split("\n"); const values = lyricsLines.map(line => { const time = line.split("[")[1].split("] ")[0]; @@ -250,49 +249,49 @@ export default class Device extends Homey.Device { const timeSeconds = String((timeColons[0] * 60) + timeColons[1]); const title = line.replace(`[${time}] `, "") || "-"; - this.lastTrackLyrics.push(timeSeconds); + this.#lastTrackLyrics.push(timeSeconds); return { id: timeSeconds, title }; }); - values.length && (lyricsValues = values); + if (values.length) lyricsValues = values; } await this.setCapabilityOptions("media_lyrics", { values: lyricsValues }); } - private async handleLyricsSync(state: Partial) { + private async handleLyricsSync(state: Types.GlagolState) { const position = state.playerState?.progress; - if (position !== undefined && this.lastTrackLyrics.length) { - const closest = this.lastTrackLyrics.find(s => Number(s) >= position) || this.lastTrackLyrics[0]; + if (position !== undefined && this.#lastTrackLyrics.length) { + const closest = this.#lastTrackLyrics.find(s => Number(s) >= position) || this.#lastTrackLyrics[0]; const between = Number(closest) - position; - if (between > 0 && !this.lastTrackLyricsTimeout) { - this.lastTrackLyricsTimeout = setTimeout(async () => { - this.lastTrackLyricsTimeout = undefined; - this.lastTrackLyrics.includes(closest) && + if (between > 0 && !this.#lastTrackLyricsTimeout) { + this.#lastTrackLyricsTimeout = setTimeout(async () => { + this.#lastTrackLyricsTimeout = undefined; + this.#lastTrackLyrics.includes(closest) && await this.setCapabilityValue("media_lyrics", closest).catch(this.error); }, between * 1000); } } } - private async handleAliceState(state: Partial) { + private async handleAliceState(state: Types.GlagolState) { const aliceState = state.aliceState || ""; - if (!this.aliceActive && (aliceState === "LISTENING" || aliceState === "SPEAKING")) { - this.aliceActive = true; - this.image.setUrl("https://i.imgur.com/vTa3rif.png"); - await this.image.update(); + if (!this.#aliceActive && (aliceState === AliceState.Listening || aliceState === AliceState.Speaking)) { + this.#aliceActive = true; + this.#image.setUrl("https://i.imgur.com/vTa3rif.png"); + await this.#image.update(); - const listener = async (state: Partial) => { - if (state.aliceState !== "IDLE") return; - this.aliceActive = false; - this.image.setUrl(this.lastTrackImage); - await this.image.update(); + const listener = async (state: Types.GlagolState) => { + if (state.aliceState !== AliceState.Idle) return; + this.#aliceActive = false; + this.#image.setUrl(this.#lastTrackImage); + await this.#image.update(); - this.speaker?.off("state", listener); + this.#speaker?.off("state", listener); }; - this.speaker?.on("state", listener); + this.#speaker?.on("state", listener); } } } \ No newline at end of file diff --git a/drivers/Driver.mts b/drivers/Driver.mts new file mode 100644 index 0000000..a34bd36 --- /dev/null +++ b/drivers/Driver.mts @@ -0,0 +1,44 @@ +import Homey from "homey"; +import type YandexApp from "../app.mjs"; +import type { Yandex } from "../library/index.js"; + +export default class Driver extends Homey.Driver { + #yandex!: Yandex; + + async onInit() { + this.#yandex = (this.homey.app as YandexApp).yandex; + } + + async onPair(session: Homey.Driver.PairSession) { + const platform = this.manifest.platform; + const devices = await this.#yandex.home.updater.getDevicesByPlatform(platform).catch(() => null); + + session.setHandler("showView", async viewId => { + if (viewId === "starting") + await session.showView(devices !== null ? "list_devices" : "login_qr"); + }); + + session.setHandler("list_devices", async () => { + return (devices || []).map(device => ({ + name: device.name, + data: { id: device.id } + })); + }); + + session.setHandler("login_start", async () => { + const authData = await this.#yandex.api.getAuthorization(); + const authTimer = setInterval(async () => { + return this.#yandex.api.checkAuthorization(authData) + .catch((error: Error) => { + if (error.message !== "Ожидание авторизации") return true; + return false; + }) + .then(value => { + if (!value) return; + clearInterval(authTimer); + session.emit("login_end", undefined); + }); + }, 2000); + }); + } +} \ No newline at end of file diff --git a/drivers/Driver.ts b/drivers/Driver.ts deleted file mode 100644 index 6ff74a8..0000000 --- a/drivers/Driver.ts +++ /dev/null @@ -1,49 +0,0 @@ -import Homey from "homey"; -import { Yandex } from "../library/index.js"; - -export default class Driver extends Homey.Driver { - private yandex!: Yandex; - - async onInit() { - const app = this.homey.app as any; - this.yandex = app.yandex; - } - - async onPair(session: Homey.Driver.PairSession) { - const platform = this.manifest.platform; - const devices = await this.yandex.home.updater.getDevicesByPlatform(platform) - .catch(() => null); - - session.setHandler("showView", async viewId => { - if (viewId !== "starting") return; - await session.showView(devices !== null ? "list_devices" : "login_qr"); - }); - - session.setHandler("login_start", async () => { - const payload = await this.yandex.api.getAuthorization(); - const checkAuth = setInterval(async () => { - const authReady = await this.yandex.api.checkAuthorization(payload) - .then(() => true) - .catch((err: Error) => { - if (err.message !== "Ожидание авторизации") - return true; - return false; - }); - - if (authReady) { - clearInterval(checkAuth); - session.emit("login_end", undefined); - } - }, 2000) - - return payload.auth_url; - }); - - session.setHandler("list_devices", async () => { - return devices!.map(device => ({ - name: device.name, - data: { id: device.id } - })); - }); - } -} \ No newline at end of file diff --git a/drivers/dexp-smartbox/device.mts b/drivers/dexp-smartbox/device.mts new file mode 100644 index 0000000..58e011a --- /dev/null +++ b/drivers/dexp-smartbox/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class DEXPSmartboxDevice extends Device {} \ No newline at end of file diff --git a/drivers/dexp-smartbox/device.ts b/drivers/dexp-smartbox/device.ts deleted file mode 100644 index b84cb53..0000000 --- a/drivers/dexp-smartbox/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class DEXPSmartboxDevice extends Device {} \ No newline at end of file diff --git a/drivers/dexp-smartbox/driver.mts b/drivers/dexp-smartbox/driver.mts new file mode 100644 index 0000000..1ada0e0 --- /dev/null +++ b/drivers/dexp-smartbox/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class DEXPSmartboxDriver extends Driver {} \ No newline at end of file diff --git a/drivers/dexp-smartbox/driver.ts b/drivers/dexp-smartbox/driver.ts deleted file mode 100644 index 85f05db..0000000 --- a/drivers/dexp-smartbox/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class DEXPSmartboxDriver extends Driver {} \ No newline at end of file diff --git a/drivers/elari-smartbeat/device.mts b/drivers/elari-smartbeat/device.mts new file mode 100644 index 0000000..5166256 --- /dev/null +++ b/drivers/elari-smartbeat/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class ElariSmartBeatDevice extends Device {} \ No newline at end of file diff --git a/drivers/elari-smartbeat/device.ts b/drivers/elari-smartbeat/device.ts deleted file mode 100644 index 65dd1a0..0000000 --- a/drivers/elari-smartbeat/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class ElariSmartBeatDevice extends Device {} \ No newline at end of file diff --git a/drivers/elari-smartbeat/driver.mts b/drivers/elari-smartbeat/driver.mts new file mode 100644 index 0000000..39bcd67 --- /dev/null +++ b/drivers/elari-smartbeat/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class ElariSmartBeatDriver extends Driver {} \ No newline at end of file diff --git a/drivers/elari-smartbeat/driver.ts b/drivers/elari-smartbeat/driver.ts deleted file mode 100644 index 2294908..0000000 --- a/drivers/elari-smartbeat/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class ElariSmartBeatDriver extends Driver {} \ No newline at end of file diff --git a/drivers/irbis-a/device.mts b/drivers/irbis-a/device.mts new file mode 100644 index 0000000..fdd5ec0 --- /dev/null +++ b/drivers/irbis-a/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class IRBISADevice extends Device {} \ No newline at end of file diff --git a/drivers/irbis-a/device.ts b/drivers/irbis-a/device.ts deleted file mode 100644 index 6203284..0000000 --- a/drivers/irbis-a/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class IRBISADevice extends Device {} \ No newline at end of file diff --git a/drivers/irbis-a/driver.mts b/drivers/irbis-a/driver.mts new file mode 100644 index 0000000..039d0d5 --- /dev/null +++ b/drivers/irbis-a/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class IRBISADriver extends Driver {} \ No newline at end of file diff --git a/drivers/irbis-a/driver.ts b/drivers/irbis-a/driver.ts deleted file mode 100644 index b9af8ca..0000000 --- a/drivers/irbis-a/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class IRBISADriver extends Driver {} \ No newline at end of file diff --git a/drivers/jbl-link-music/device.mts b/drivers/jbl-link-music/device.mts new file mode 100644 index 0000000..7929463 --- /dev/null +++ b/drivers/jbl-link-music/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class JBLLinkMusicDevice extends Device {} \ No newline at end of file diff --git a/drivers/jbl-link-music/device.ts b/drivers/jbl-link-music/device.ts deleted file mode 100644 index d689e89..0000000 --- a/drivers/jbl-link-music/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class JBLLinkMusicDevice extends Device {} \ No newline at end of file diff --git a/drivers/jbl-link-music/driver.mts b/drivers/jbl-link-music/driver.mts new file mode 100644 index 0000000..a31829b --- /dev/null +++ b/drivers/jbl-link-music/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class JBLLinkMusicDriver extends Driver {} \ No newline at end of file diff --git a/drivers/jbl-link-music/driver.ts b/drivers/jbl-link-music/driver.ts deleted file mode 100644 index 66df97c..0000000 --- a/drivers/jbl-link-music/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class JBLLinkMusicDriver extends Driver {} \ No newline at end of file diff --git a/drivers/jbl-link-portable/device.mts b/drivers/jbl-link-portable/device.mts new file mode 100644 index 0000000..cef0a04 --- /dev/null +++ b/drivers/jbl-link-portable/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class JBLLinkPortableDevice extends Device {} \ No newline at end of file diff --git a/drivers/jbl-link-portable/device.ts b/drivers/jbl-link-portable/device.ts deleted file mode 100644 index f834a1c..0000000 --- a/drivers/jbl-link-portable/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class JBLLinkPortableDevice extends Device {} \ No newline at end of file diff --git a/drivers/jbl-link-portable/driver.mts b/drivers/jbl-link-portable/driver.mts new file mode 100644 index 0000000..72c6c02 --- /dev/null +++ b/drivers/jbl-link-portable/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class JBLLinkPortableDriver extends Driver {} \ No newline at end of file diff --git a/drivers/jbl-link-portable/driver.ts b/drivers/jbl-link-portable/driver.ts deleted file mode 100644 index ca90ae9..0000000 --- a/drivers/jbl-link-portable/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class JBLLinkPortableDriver extends Driver {} \ No newline at end of file diff --git a/drivers/lg-xboom-ai-thinq/device.mts b/drivers/lg-xboom-ai-thinq/device.mts new file mode 100644 index 0000000..af8b8a7 --- /dev/null +++ b/drivers/lg-xboom-ai-thinq/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class LGXBOOMAIThinQDevice extends Device {} \ No newline at end of file diff --git a/drivers/lg-xboom-ai-thinq/device.ts b/drivers/lg-xboom-ai-thinq/device.ts deleted file mode 100644 index 32e32f8..0000000 --- a/drivers/lg-xboom-ai-thinq/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class LGXBOOMAIThinQDevice extends Device {} \ No newline at end of file diff --git a/drivers/lg-xboom-ai-thinq/driver.mts b/drivers/lg-xboom-ai-thinq/driver.mts new file mode 100644 index 0000000..965a929 --- /dev/null +++ b/drivers/lg-xboom-ai-thinq/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class LGXBOOMAIThinQDriver extends Driver {} \ No newline at end of file diff --git a/drivers/lg-xboom-ai-thinq/driver.ts b/drivers/lg-xboom-ai-thinq/driver.ts deleted file mode 100644 index ce679bd..0000000 --- a/drivers/lg-xboom-ai-thinq/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class LGXBOOMAIThinQDriver extends Driver {} \ No newline at end of file diff --git a/drivers/prestigio-smartmate/device.mts b/drivers/prestigio-smartmate/device.mts new file mode 100644 index 0000000..125d076 --- /dev/null +++ b/drivers/prestigio-smartmate/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class PrestigioSmartmateDevice extends Device {} \ No newline at end of file diff --git a/drivers/prestigio-smartmate/device.ts b/drivers/prestigio-smartmate/device.ts deleted file mode 100644 index d589658..0000000 --- a/drivers/prestigio-smartmate/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class PrestigioSmartmateDevice extends Device {} \ No newline at end of file diff --git a/drivers/prestigio-smartmate/driver.mts b/drivers/prestigio-smartmate/driver.mts new file mode 100644 index 0000000..752e37d --- /dev/null +++ b/drivers/prestigio-smartmate/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class PrestigioSmartmateDriver extends Driver {} \ No newline at end of file diff --git a/drivers/prestigio-smartmate/driver.ts b/drivers/prestigio-smartmate/driver.ts deleted file mode 100644 index 85e0d66..0000000 --- a/drivers/prestigio-smartmate/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class PrestigioSmartmateDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-2/device.mts b/drivers/yandex-station-2/device.mts new file mode 100644 index 0000000..deca378 --- /dev/null +++ b/drivers/yandex-station-2/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class YandexStationTwoDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-2/device.ts b/drivers/yandex-station-2/device.ts deleted file mode 100644 index a6b9c9e..0000000 --- a/drivers/yandex-station-2/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class YandexStationTwoDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-2/driver.mts b/drivers/yandex-station-2/driver.mts new file mode 100644 index 0000000..708b520 --- /dev/null +++ b/drivers/yandex-station-2/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class YandexStationTwoDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-2/driver.ts b/drivers/yandex-station-2/driver.ts deleted file mode 100644 index b0972b8..0000000 --- a/drivers/yandex-station-2/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class YandexStationTwoDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-duo-max/device.mts b/drivers/yandex-station-duo-max/device.mts new file mode 100644 index 0000000..715d1db --- /dev/null +++ b/drivers/yandex-station-duo-max/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class YandexStationDuoMaxDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-duo-max/device.ts b/drivers/yandex-station-duo-max/device.ts deleted file mode 100644 index e9e21ad..0000000 --- a/drivers/yandex-station-duo-max/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class YandexStationDuoMaxDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-duo-max/driver.mts b/drivers/yandex-station-duo-max/driver.mts new file mode 100644 index 0000000..5f91592 --- /dev/null +++ b/drivers/yandex-station-duo-max/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class YandexStationDuoMaxDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-duo-max/driver.ts b/drivers/yandex-station-duo-max/driver.ts deleted file mode 100644 index b036223..0000000 --- a/drivers/yandex-station-duo-max/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class YandexStationDuoMaxDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-lite/device.mts b/drivers/yandex-station-lite/device.mts new file mode 100644 index 0000000..f92d636 --- /dev/null +++ b/drivers/yandex-station-lite/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class YandexStationLiteDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-lite/device.ts b/drivers/yandex-station-lite/device.ts deleted file mode 100644 index 9145bce..0000000 --- a/drivers/yandex-station-lite/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class YandexStationLiteDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-lite/driver.mts b/drivers/yandex-station-lite/driver.mts new file mode 100644 index 0000000..af19271 --- /dev/null +++ b/drivers/yandex-station-lite/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class YandexStationLiteDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-lite/driver.ts b/drivers/yandex-station-lite/driver.ts deleted file mode 100644 index ebe7975..0000000 --- a/drivers/yandex-station-lite/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class YandexStationLiteDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-max/device.mts b/drivers/yandex-station-max/device.mts new file mode 100644 index 0000000..5301708 --- /dev/null +++ b/drivers/yandex-station-max/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class YandexStationMaxDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-max/device.ts b/drivers/yandex-station-max/device.ts deleted file mode 100644 index 8ba0bba..0000000 --- a/drivers/yandex-station-max/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class YandexStationMaxDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-max/driver.mts b/drivers/yandex-station-max/driver.mts new file mode 100644 index 0000000..bb571ff --- /dev/null +++ b/drivers/yandex-station-max/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class YandexStationMaxDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-max/driver.ts b/drivers/yandex-station-max/driver.ts deleted file mode 100644 index 30736fe..0000000 --- a/drivers/yandex-station-max/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class YandexStationMaxDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-midi/device.mts b/drivers/yandex-station-midi/device.mts new file mode 100644 index 0000000..884f803 --- /dev/null +++ b/drivers/yandex-station-midi/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class YandexStationMidiDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-midi/device.ts b/drivers/yandex-station-midi/device.ts deleted file mode 100644 index 29ff701..0000000 --- a/drivers/yandex-station-midi/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class YandexStationMidiDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-midi/driver.mts b/drivers/yandex-station-midi/driver.mts new file mode 100644 index 0000000..8d20d77 --- /dev/null +++ b/drivers/yandex-station-midi/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class YandexStationMidiDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-midi/driver.ts b/drivers/yandex-station-midi/driver.ts deleted file mode 100644 index f05aab4..0000000 --- a/drivers/yandex-station-midi/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class YandexStationMidiDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-mini-2/device.mts b/drivers/yandex-station-mini-2/device.mts new file mode 100644 index 0000000..4bcf251 --- /dev/null +++ b/drivers/yandex-station-mini-2/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class YandexStationMiniTwoDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-mini-2/device.ts b/drivers/yandex-station-mini-2/device.ts deleted file mode 100644 index 2add299..0000000 --- a/drivers/yandex-station-mini-2/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class YandexStationMiniTwoDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-mini-2/driver.mts b/drivers/yandex-station-mini-2/driver.mts new file mode 100644 index 0000000..35df3e2 --- /dev/null +++ b/drivers/yandex-station-mini-2/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class YandexStationMiniTwoDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-mini-2/driver.ts b/drivers/yandex-station-mini-2/driver.ts deleted file mode 100644 index 5bc335e..0000000 --- a/drivers/yandex-station-mini-2/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class YandexStationMiniTwoDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-mini/device.mts b/drivers/yandex-station-mini/device.mts new file mode 100644 index 0000000..ae74c70 --- /dev/null +++ b/drivers/yandex-station-mini/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class YandexStationMiniDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-mini/device.ts b/drivers/yandex-station-mini/device.ts deleted file mode 100644 index 0bc28cc..0000000 --- a/drivers/yandex-station-mini/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class YandexStationMiniDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station-mini/driver.mts b/drivers/yandex-station-mini/driver.mts new file mode 100644 index 0000000..ed8555a --- /dev/null +++ b/drivers/yandex-station-mini/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class YandexStationMiniDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station-mini/driver.ts b/drivers/yandex-station-mini/driver.ts deleted file mode 100644 index c77f328..0000000 --- a/drivers/yandex-station-mini/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class YandexStationMiniDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station/device.mts b/drivers/yandex-station/device.mts new file mode 100644 index 0000000..09d7ab2 --- /dev/null +++ b/drivers/yandex-station/device.mts @@ -0,0 +1,3 @@ +import Device from "../Device.mjs"; + +export default class YandexStationDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station/device.ts b/drivers/yandex-station/device.ts deleted file mode 100644 index 7f763fc..0000000 --- a/drivers/yandex-station/device.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Device from "../Device.js"; - -module.exports = class YandexStationDevice extends Device {} \ No newline at end of file diff --git a/drivers/yandex-station/driver.mts b/drivers/yandex-station/driver.mts new file mode 100644 index 0000000..8b2996b --- /dev/null +++ b/drivers/yandex-station/driver.mts @@ -0,0 +1,3 @@ +import Driver from "../Driver.mjs"; + +export default class YandexStationDriver extends Driver {} \ No newline at end of file diff --git a/drivers/yandex-station/driver.ts b/drivers/yandex-station/driver.ts deleted file mode 100644 index 8ebbd41..0000000 --- a/drivers/yandex-station/driver.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Driver from "../Driver.js"; - -module.exports = class YandexStationDriver extends Driver {} \ No newline at end of file diff --git a/library/api/index.ts b/library/api/index.ts index de8fc24..5bfc484 100644 --- a/library/api/index.ts +++ b/library/api/index.ts @@ -1,5 +1,6 @@ import type { AxiosInstance } from "axios"; -import { YandexStorage } from "../storage.js"; +import type { YandexStorage } from "../storage.js"; +import type * as Types from "../typings/index.js"; import { YandexAliceAPI } from "./services/alice.js"; import { YandexMusicAPI } from "./services/music.js"; import { YandexPassportAPI } from "./services/passport.js"; @@ -7,25 +8,25 @@ import { YandexQuasarAPI } from "./services/quasar.js"; import { createInstance } from "./utils.js"; export class YandexAPI { - readonly request: AxiosInstance; - readonly alice: YandexAliceAPI; - readonly music: YandexMusicAPI; - readonly passport: YandexPassportAPI; - readonly quasar: YandexQuasarAPI; + readonly request: AxiosInstance; + readonly alice: YandexAliceAPI; + readonly music: YandexMusicAPI; + readonly passport: YandexPassportAPI; + readonly quasar: YandexQuasarAPI; - constructor(storage: YandexStorage) { - this.request = createInstance(storage, config => config); - this.alice = new YandexAliceAPI(storage); - this.passport = new YandexPassportAPI(storage); - this.quasar = new YandexQuasarAPI(storage, this.passport); - this.music = new YandexMusicAPI(storage, this.passport); - } + constructor(storage: YandexStorage) { + this.request = createInstance(storage, (config) => config); + this.alice = new YandexAliceAPI(storage); + this.passport = new YandexPassportAPI(storage); + this.quasar = new YandexQuasarAPI(storage, this.passport); + this.music = new YandexMusicAPI(storage, this.passport); + } - async getAuthorization() { - return await this.passport.getMagicAuthorization(); - } + async getAuthorization() { + return await this.passport.getMagicAuthorization(); + } - async checkAuthorization(payload: any) { - return await this.passport.checkMagicAuthorization(payload); - } -} \ No newline at end of file + async checkAuthorization(authData: Types.AuthData) { + return await this.passport.checkMagicAuthorization(authData); + } +} diff --git a/library/api/services/alice.ts b/library/api/services/alice.ts index 0a64b32..a2934d6 100644 --- a/library/api/services/alice.ts +++ b/library/api/services/alice.ts @@ -1,113 +1,74 @@ import type { AxiosInstance } from "axios"; import type { YandexStorage } from "../../storage.js"; +import type * as Types from "../../typings/index.js"; import { createInstance } from "../utils.js"; -export type YandexDayOfWeek = Array<"Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday">; - -export type YandexAlarm = { - device_id: string; // "XW000000000000000000000000000000" - alarm_id: string; // "00000000-00000000-00000000-00000000" - enabled: boolean; // true - time: string; // "01:23" -} & ({ - date?: string; // "2025-01-23" -} | { - recurring?: { - days_of_week: YandexDayOfWeek; - }; -}); - -export type YandexReminder = { - id: string; // "00000000-00000000-00000000-00000000" - text: string; // "Текст напоминания" - properties: any; - play_settings: { - send_push: { - state: "Enabled" | "Disabled"; - }; - play_location?: { - selected_devices: { - selected_device_ids: string[]; // ["XW000000000000000000000000000000"] - }; - }; - }; - trigger_policy: { - single_trigger_policy: { - datetime: string; // "2025-01-23T01:23:45" - }; - } | { - weekdays_trigger_policy: { - time: string; // "01:23:45" - day_of_week: YandexDayOfWeek; - }; - }; -}; - export class YandexAliceAPI { - private client: AxiosInstance; + #client: AxiosInstance; + + constructor(storage: YandexStorage) { + this.#client = createInstance(storage, (config) => ({ + ...config, + baseURL: "https://rpc.alice.yandex.ru/gproxy", + headers: { + ...config.headers, + Accept: "application/json", + Origin: "https://yandex.ru", + "x-ya-app-type": "iot-app", + "x-ya-application": '{"app_id":"unknown","uuid":"unknown","lang":"ru"}', + }, + })); + } - constructor(storage: YandexStorage) { - this.client = createInstance(storage, config => ({ - ...config, - headers: { - ...config.headers, - "Accept": "application/json", - "Origin": "https://yandex.ru", - "x-ya-app-type": "iot-app", - "x-ya-application": '{"app_id":"unknown","uuid":"unknown","lang":"ru"}', - } - })); - } + get request() { + return this.#client; + } - async getAlarms(deviceIds: Array) { - return await this.client - .post("https://rpc.alice.yandex.ru/gproxy/get_alarms", { device_ids: deviceIds }) - .then(res => res.data.alarms as Array); - } + async getAlarms(deviceIds: string[]) { + return await this.#client + .post("/get_alarms", { device_ids: deviceIds }) + .then((res) => res.data.alarms as Types.Alarm[]); + } - async createAlarm(alarm: YandexAlarm, deviceType: string) { - return await this.client - .post("https://rpc.alice.yandex.ru/gproxy/create_alarm", { alarm, device_type: deviceType }) - .then(res => res.data.alarm as YandexAlarm); - } + async createAlarm(alarm: Types.Alarm, deviceType: string) { + return await this.#client + .post("/create_alarm", { alarm, device_type: deviceType }) + .then((res) => res.data.alarm as Types.Alarm); + } - async changeAlarm(alarm: YandexAlarm, deviceType: string) { - return await this.client - .post("https://rpc.alice.yandex.ru/gproxy/change_alarm", { alarm, device_type: deviceType }) - .then(res => res.data.alarm as YandexAlarm); - } + async changeAlarm(alarm: Types.Alarm, deviceType: string) { + return await this.#client + .post("/change_alarm", { alarm, device_type: deviceType }) + .then((res) => res.data.alarm as Types.Alarm); + } - async cancelAlarms(deviceAlarmIds: Array<{ alarm_id: string, device_id: string }>) { - await this.client - .post("https://rpc.alice.yandex.ru/gproxy/cancel_alarms", { device_alarm_ids: deviceAlarmIds }); - } + async cancelAlarms(deviceAlarmIds: Array<{ alarm_id: string; device_id: string }>) { + await this.#client.post("/cancel_alarms", { device_alarm_ids: deviceAlarmIds }); + } - async cancelAlarm(alarmId: string, deviceId: string) { - await this.cancelAlarms([{ alarm_id: alarmId, device_id: deviceId }]); - } + async cancelAlarm(alarmId: string, deviceId: string) { + await this.cancelAlarms([{ alarm_id: alarmId, device_id: deviceId }]); + } - async getReminders() { - return await this.client - .get("https://rpc.alice.yandex.ru/gproxy/get_reminders") - .then(res => res.data.reminders as Array); - } + async getReminders() { + return await this.#client.get("/get_reminders").then((res) => res.data.reminders as Types.Reminder[]); + } - async createReminder(reminder: YandexReminder) { - await this.client - .post("https://rpc.alice.yandex.ru/gproxy/create_reminder", reminder); - } + async createReminder(reminder: Types.Reminder) { + await this.#client.post("/create_reminder", reminder); + } - async updateReminder(reminder: YandexReminder) { - await this.client - .post("https://rpc.alice.yandex.ru/gproxy/update_reminder", reminder); - } + async updateReminder(reminder: Types.Reminder) { + await this.#client.post("/update_reminder", reminder); + } - async cancelReminders(reminderIds: string[]) { - await this.client - .post("https://rpc.alice.yandex.ru/gproxy/cancel_reminders", { cancel_selected: { reminder_ids: reminderIds } }); - } + async cancelReminders(reminderIds: string[]) { + await this.#client.post("/cancel_reminders", { + cancel_selected: { reminder_ids: reminderIds }, + }); + } - async cancelReminder(reminderId: string) { - await this.cancelReminders([reminderId]); - } -} \ No newline at end of file + async cancelReminder(reminderId: string) { + await this.cancelReminders([reminderId]); + } +} diff --git a/library/api/services/music.ts b/library/api/services/music.ts index 587b72b..b90e0ef 100644 --- a/library/api/services/music.ts +++ b/library/api/services/music.ts @@ -1,107 +1,95 @@ +import { createHmac } from "node:crypto"; +import qs from "node:querystring"; import type { AxiosInstance } from "axios"; import type { YandexStorage } from "../../storage.js"; -import type { YandexPassportAPI } from "./passport.js"; -import { createHmac } from "crypto"; -import qs from "querystring"; import { createInstance } from "../utils.js"; +import type { YandexPassportAPI } from "./passport.js"; export class YandexMusicAPI { - private client: AxiosInstance; - - constructor(storage: YandexStorage, private passport: YandexPassportAPI) { - this.client = createInstance(storage, config => ({ - ...config, - headers: { - ...config.headers, - "X-Yandex-Music-Client": "YandexMusicAndroid/24023621", - "X-Yandex-Music-Content-Type": "adult" - } - })); - - this.client.interceptors.request.use(async request => { - request.headers.set("Authorization", `OAuth ${ await this.passport.getMusicToken() }`); - return request; - }); - } - - get request() { - return this.client; - } - - async getAccountStatus() { - return await this.client - .get("https://api.music.yandex.net/account/status") - .then(res => res.data.result); - } - - async getTracks(trackIds: Array) { - return await this.client - .post("https://api.music.yandex.net/tracks", qs.stringify({ "track-ids": trackIds })) - .then(res => res.data.result); - } - - async getTrack(trackId: string) { - return await this.getTracks([trackId]) - .then(tracks => tracks[0]); - } - - async getLyrics(trackId: string) { - const timestamp = Math.round(Date.now() / 1000); - const hmac = createHmac("sha256", "p93jhgh689SBReK6ghtw62") - .update(`${trackId}${timestamp}`) - .digest(); - - const params = { timeStamp: timestamp, sign: hmac.toString("base64") }; - const lyricsUrl = await this.client - .get(`https://api.music.yandex.net/tracks/${trackId}/lyrics`, { params }) - .then(res => res.data.result.downloadUrl); - - return await this.client - .get(lyricsUrl) - .then(res => res.data as string); - } - - async getLikes(userId: string) { - return await this.client - .get(`https://api.music.yandex.net/users/${userId}/likes/tracks`) - .then(res => res.data.result.library.tracks as any[]); - } - - async addLike(userId: string, trackId: string, albumId: string) { - const params = new URLSearchParams(); - params.append("track-ids", `${trackId}:${albumId}`); - - return await this.client - .post(`https://api.music.yandex.net/users/${userId}/likes/tracks/add-multiple`, undefined, { params }); - } - - async removeLike(userId: string, trackId: string) { - const params = new URLSearchParams(); - params.append("track-ids", `${trackId}`); - - return await this.client - .post(`https://api.music.yandex.net/users/${userId}/likes/tracks/remove`, undefined, { params }); - } - - async getDislikes(userId: string) { - return await this.client - .get(`https://api.music.yandex.net/users/${userId}/dislikes/tracks`) - .then(res => res.data.result.library.tracks as any[]); - } - - async addDislike(userId: string, trackId: string, albumId: string) { - const params = new URLSearchParams(); - params.append("track-ids", `${trackId}:${albumId}`); - - return await this.client - .post(`https://api.music.yandex.net/users/${userId}/dislikes/tracks/add-multiple`, undefined, { params }); - } - - async removeDislike(userId: string, trackId: string) { - const params = new URLSearchParams(); - params.append("track-ids", `${trackId}`); - - return await this.client - .post(`https://api.music.yandex.net/users/${userId}/dislikes/tracks/remove`, undefined, { params }); - } -} \ No newline at end of file + #client: AxiosInstance; + + constructor(storage: YandexStorage, passport: YandexPassportAPI) { + this.#client = createInstance(storage, (config) => ({ + ...config, + baseURL: "https://api.music.yandex.net", + headers: { + ...config.headers, + "X-Yandex-Music-Client": "YandexMusicAndroid/24023621", + "X-Yandex-Music-Content-Type": "adult", + }, + })); + + this.#client.interceptors.request.use(async (request) => { + request.headers.set("Authorization", `OAuth ${await passport.getMusicToken()}`); + return request; + }); + } + + get request() { + return this.#client; + } + + async getAccountStatus() { + return await this.#client.get("/account/status").then((res) => res.data.result); + } + + async getTracks(trackIds: string[]) { + return await this.#client.post("/tracks", qs.stringify({ "track-ids": trackIds })).then((res) => res.data.result); + } + + async getTrack(trackId: string) { + return await this.getTracks([trackId]).then((tracks) => tracks[0]); + } + + async getLyrics(trackId: string) { + const timestamp = Math.round(Date.now() / 1000); + const hmac = createHmac("sha256", "p93jhgh689SBReK6ghtw62").update(`${trackId}${timestamp}`).digest(); + + const params = { timeStamp: timestamp, sign: hmac.toString("base64") }; + const lyricsUrl = await this.#client + .get(`/tracks/${trackId}/lyrics`, { params }) + .then((res) => res.data.result.downloadUrl); + + return await this.#client.get(lyricsUrl).then((res) => res.data as string); + } + + async getLikes(userId: string) { + return await this.#client + .get(`/users/${userId}/likes/tracks`) + .then((res) => res.data.result.library.tracks as any[]); + } + + async addLike(userId: string, trackId: string, albumId: string) { + const params = new URLSearchParams(); + params.append("track-ids", `${trackId}:${albumId}`); + + return await this.#client.post(`/users/${userId}/likes/tracks/add-multiple`, undefined, { params }); + } + + async removeLike(userId: string, trackId: string) { + const params = new URLSearchParams(); + params.append("track-ids", `${trackId}`); + + return await this.#client.post(`/users/${userId}/likes/tracks/remove`, undefined, { params }); + } + + async getDislikes(userId: string) { + return await this.#client + .get(`/users/${userId}/dislikes/tracks`) + .then((res) => res.data.result.library.tracks as any[]); + } + + async addDislike(userId: string, trackId: string, albumId: string) { + const params = new URLSearchParams(); + params.append("track-ids", `${trackId}:${albumId}`); + + return await this.#client.post(`/users/${userId}/dislikes/tracks/add-multiple`, undefined, { params }); + } + + async removeDislike(userId: string, trackId: string) { + const params = new URLSearchParams(); + params.append("track-ids", `${trackId}`); + + return await this.#client.post(`/users/${userId}/dislikes/tracks/remove`, undefined, { params }); + } +} diff --git a/library/api/services/passport.ts b/library/api/services/passport.ts index 8cf30aa..c9d2aa2 100644 --- a/library/api/services/passport.ts +++ b/library/api/services/passport.ts @@ -1,87 +1,98 @@ +import qs from "node:querystring"; import type { AxiosInstance } from "axios"; import type { YandexStorage } from "../../storage.js"; -import qs from "querystring"; +import type * as Types from "../../typings/index.js"; import { createInstance } from "../utils.js"; export class YandexPassportAPI { - private client: AxiosInstance; + #storage: YandexStorage; + #client: AxiosInstance; - constructor(private storage: YandexStorage) { - this.client = createInstance(storage, config => config); - } + constructor(storage: YandexStorage) { + this.#storage = storage; + this.#client = createInstance(storage, (config) => ({ + ...config, + baseURL: "https://passport.yandex.ru", + })); + } - async #getToken(clientId: string, clientSecret: string) { - return await this.storage - .getToken(clientId) - .catch(async () => { - const cookie = await this.storage.getCookies("https://yandex.ru"); - const headers = { "Ya-Client-Host": "passport.yandex.ru", "Ya-Client-Cookie": cookie }; - const data = qs.stringify({ client_id: clientId, client_secret: clientSecret }); - - return this.client - .post("https://mobileproxy.passport.yandex.net/1/bundle/oauth/token_by_sessionid", data, { headers }) - .then(res => this.storage.setToken(clientId, res.data)); - }); - } + get request() { + return this.#client; + } - async getAccountToken() { - const cliendId = "c0ebe342af7d48fbbbfcf2d2eedb8f9e"; - const clientSecret = "ad0a908f0aa341a182a37ecd75bc319e"; - return await this.#getToken(cliendId, clientSecret); - } + async #getToken(clientId: string, clientSecret: string) { + return await this.#storage.getToken(clientId).catch(async () => { + const cookie = await this.#storage.getCookies("https://yandex.ru"); + const headers = { + "Ya-Client-Host": "passport.yandex.ru", + "Ya-Client-Cookie": cookie, + }; + const data = qs.stringify({ + client_id: clientId, + client_secret: clientSecret, + }); - async getMusicToken() { - const cliendId = "23cabbbdc6cd418abb4b39c32c41195d"; - const clientSecret = "53bc75238f0c4d08a118e51fe9203300"; - return await this.#getToken(cliendId, clientSecret); - } + return this.#client + .post("/1/bundle/oauth/token_by_sessionid", data, { + baseURL: "https://mobileproxy.passport.yandex.net", + headers, + }) + .then((res) => this.#storage.setToken(clientId, res.data)); + }); + } - async getMagicAuthorization() { - const csrf_token = await this.client - .get("https://passport.yandex.ru/am?app_platform=android") - .then(res => { - const match = res.data.match('"csrf_token" value="([^"]+)"'); - if (match === null || match.length <= 1) throw new Error("Нет CSRF-токена"); - return match[1]; - }); - - const body = qs.stringify({ retpath: "https://passport.yandex.ru/profile", csrf_token, with_code: 1 }); + async getAccountToken() { + const cliendId = "c0ebe342af7d48fbbbfcf2d2eedb8f9e"; + const clientSecret = "ad0a908f0aa341a182a37ecd75bc319e"; + return await this.#getToken(cliendId, clientSecret); + } - return await this.client - .post("https://passport.yandex.ru/registration-validations/auth/password/submit", body) - .then(res => { - if (res.data.status !== "ok") throw new Error("Неизвестная ошибка"); - return { - auth_url: `https://passport.yandex.ru/am/push/qrsecure?track_id=${res.data.track_id}`, - track_id: res.data.track_id, - csrf_token: res.data.csrf_token - }; - }); - } + async getMusicToken() { + const cliendId = "23cabbbdc6cd418abb4b39c32c41195d"; + const clientSecret = "53bc75238f0c4d08a118e51fe9203300"; + return await this.#getToken(cliendId, clientSecret); + } - async checkMagicAuthorization(payload: any) { - const { auth_url, ...data } = payload; - - return await this.client - .post("https://passport.yandex.ru/auth/new/magic/status", qs.stringify(data), { maxRedirects: 0 }) - .then(async res => { - const { status, errors } = res.data; + async getMagicAuthorization() { + const csrf_token = await this.#client.get("/am?app_platform=android").then((res) => { + const match = res.data.match('"csrf_token" value="([^"]+)"'); + if (match === null || match.length <= 1) throw new Error("Нет CSRF-токена"); + return match[1]; + }); - if (Object.keys(res.data).length === 0) - throw new Error("Ожидание авторизации"); + const body = qs.stringify({ + retpath: "https://passport.yandex.ru/profile", + csrf_token, + with_code: 1, + }); - if (errors) { - if (errors.includes("account.auth_passed")) - throw new Error("Авторизация уже пройдена"); - - if (errors.includes("track.not_found")) - throw new Error("Данные авторизации устарели"); - } + return await this.#client.post("/registration-validations/auth/password/submit", body).then((res) => { + if (res.data.status !== "ok") throw new Error("Неизвестная ошибка"); + return { + auth_url: `https://passport.yandex.ru/am/push/qrsecure?track_id=${res.data.track_id}`, + csrf_token: res.data.csrf_token, + track_id: res.data.track_id, + } as Types.AuthData; + }); + } - if (status !== "ok") - throw new Error(`Неизвестная ошибка: ${JSON.stringify(res.data)}`); + async checkMagicAuthorization(authData: Types.AuthData) { + const { auth_url, ...data } = authData; - await this.getAccountToken(); - }); - } -} \ No newline at end of file + return await this.#client + .post("/auth/new/magic/status", qs.stringify(data), { maxRedirects: 0 }) + .then(async (res) => { + const { status, errors } = res.data; + + if (Object.keys(res.data).length === 0) throw new Error("Ожидание авторизации"); + if (errors) { + if (errors.includes("account.auth_passed")) throw new Error("Авторизация уже пройдена"); + if (errors.includes("track.not_found")) throw new Error("Данные авторизации устарели"); + } + if (status !== "ok") throw new Error(`Неизвестная ошибка: ${JSON.stringify(res.data)}`); + + await this.getAccountToken(); + return true; + }); + } +} diff --git a/library/api/services/quasar.ts b/library/api/services/quasar.ts index 4ee1e2d..9f9054d 100644 --- a/library/api/services/quasar.ts +++ b/library/api/services/quasar.ts @@ -1,122 +1,156 @@ import axios, { type AxiosInstance } from "axios"; -import { YandexStorage } from "../../storage.js"; -import { YandexPassportAPI } from "./passport.js"; +import type { YandexStorage } from "../../storage.js"; +import type * as Types from "../../typings/index.js"; import { createInstance } from "../utils.js"; +import type { YandexPassportAPI } from "./passport.js"; export class YandexQuasarAPI { - private client: AxiosInstance; - #csrfToken?: string; - - constructor(storage: YandexStorage, private passport: YandexPassportAPI) { - this.client = createInstance(storage, config => config); - - this.client.interceptors.request.use(async request => { - if (request.method === "get" && request.url?.includes("/glagol/") || request.url?.includes("/muspult/")) - request.headers.set("Authorization", `OAuth ${ await this.passport.getMusicToken() }`); - - if (["post", "put", "delete"].includes(request.method || "")) - request.headers.set("x-csrf-token", await this.#getCsrfToken()); - - return request; - }); - - this.client.interceptors.response.use(undefined, async error => { - if (axios.isAxiosError(error) && error.response?.status === 403) { - this.#csrfToken = undefined; - if (error.config) return await this.client.request(error.config); - } - - throw error; - }); - } - - get request() { - return this.client; - } - - async #getCsrfToken() { - if (this.#csrfToken) return this.#csrfToken; - return await this.client - .get("https://quasar.yandex.ru/csrf_token") - .then(res => this.#csrfToken = res.data.token as string); - } - - async getGlagolToken(deviceId: string, platform: string) { - const params = { device_id: deviceId, platform }; - - return await this.client - .get("https://quasar.yandex.ru/glagol/token", { params }) - .then(res => res.data.token as string); - } - - async getGlagolDevices() { - return await this.client - .get("https://quasar.yandex.ru/glagol/device_list") - .then(res => res.data.devices); - } - - async getAudioDevices() { - return await this.client - .get("https://iot.quasar.yandex.ru/glagol/user/info?scope=audio") - .then(res => res.data.devices); - } - - async getAccountConfig() { - return await this.client - .get("https://quasar.yandex.ru/get_account_config") - .then(res => res.data.config); - } - - async getDevices() { - return await this.client - .get("https://iot.quasar.yandex.ru/m/v3/user/devices") - .then(res => res.data as any); - } - - async getDevicesQuasarConfig() { - return await this.client - .get("https://iot.quasar.yandex.ru/m/user/devices/quasar/configuration") - .then(res => res.data.devices); - } - - async runDeviceAction(id: string, actions: any[]) { - return await this.client - .post(`https://iot.quasar.yandex.ru/m/user/devices/${id}/actions`, { actions }) - .then(() => {}); - } - - async getScenarios() { - return await this.client - .get("https://iot.quasar.yandex.ru/m/user/scenarios") - .then(res => res.data.scenarios); - } - - async getScenarioIcons() { - return await this.client - .get("https://iot.quasar.yandex.ru/m/user/scenarios/icons") - .then(res => res.data); - } - - async createScenario(data: any) { - // status: 'ok', - // request_id: '9c465ddd-5e8d-4909-a14b-1f5d55c9e484', - // scenario_id: '5cc7b99c-5a55-434a-a6f3-554064d318db' - return await this.client - .post("https://iot.quasar.yandex.ru/m/v3/user/scenarios", data) - .then(res => { - const status = res.data.status; - if (status !== 'ok') throw new Error(res.data.message ?? res.data.code); - return res.data; - }); - } - - async editScenario(scenarioId: string, data: any) { - await this.client - .put(`https://iot.quasar.yandex.ru/m/user/scenarios/${scenarioId}`, data); - } - - async runScenarioAction(scenarioId: string) { - await this.client - .post(`https://iot.quasar.yandex.ru/m/user/scenarios/${scenarioId}/actions`); - } -} \ No newline at end of file + #passport: YandexPassportAPI; + #client: AxiosInstance; + #csrfToken?: string; + + constructor(storage: YandexStorage, passport: YandexPassportAPI) { + this.#passport = passport; + this.#client = createInstance(storage, (config) => config); + + this.#client.interceptors.request.use(async (request) => { + if ((request.method === "get" && request.url?.includes("/glagol/")) || request.url?.includes("/muspult/")) + request.headers.set("Authorization", `OAuth ${await this.#passport.getMusicToken()}`); + + if (["post", "put", "delete"].includes(request.method || "")) + request.headers.set("x-csrf-token", await this.#getCsrfToken()); + + return request; + }); + + this.#client.interceptors.response.use(undefined, async (error) => { + if (axios.isAxiosError(error) && error.response?.status === 403) { + this.#csrfToken = undefined; + if (error.config) return await this.#client.request(error.config); + } + + throw error; + }); + } + + get request() { + return this.#client; + } + + async #getCsrfToken() { + if (this.#csrfToken) return this.#csrfToken; + return await this.#client.get("https://quasar.yandex.ru/csrf_token").then((res) => { + this.#csrfToken = res.data.token as string; + return this.#csrfToken; + }); + } + + async getGlagolToken(deviceId: string, platform: string) { + const params = { device_id: deviceId, platform }; + + return await this.#client + .get("https://quasar.yandex.ru/glagol/token", { params }) + .then((res) => res.data.token as string); + } + + async getGlagolDevices() { + return await this.#client + .get("https://quasar.yandex.ru/glagol/device_list") + .then((res) => res.data.devices as Types.GlagolDevice[]); + } + + async getAudioDevices() { + return await this.#client + .get("https://iot.quasar.yandex.ru/glagol/user/info?scope=audio") + .then((res) => res.data.devices as Types.GlagolAudioInfo[]); + } + + async getAccountConfig() { + return await this.#client + .get("https://quasar.yandex.ru/get_account_config") + .then((res) => res.data.config as Types.QuasarAccountConfig); + } + + async setAccountConfig(config: Types.QuasarAccountConfig) { + return await this.#client + .post("https://quasar.yandex.ru/set_account_config", config) + .then((res) => res.data.config); + } + + async getDevices() { + return await this.#client.get("https://iot.quasar.yandex.ru/m/v3/user/devices").then( + (res) => + res.data as { + status: string; + request_id: string; + households: Types.HouseholdV3[]; + favorites: { + properties: any[]; + items: Array<{ + type: string; + parameters: Types.DeviceV3; + household_id: string; + room_id: string; + }>; + background_image: { + id: string; + }; + }; + updates_url: string; + }, + ); + } + + async getDeviceConfig(deviceId: string) { + // https://iot.quasar.yandex.ru/m/v2/user/devices/fa811d18-bd07-4b0e-8c4c-97f34af1e896/configuration + } + + async setDeviceConfig(deviceId: string, config: any) { + // https://iot.quasar.yandex.ru/m/v3/user/devices/fa811d18-bd07-4b0e-8c4c-97f34af1e896/configuration/quasar + } + + async dingDevice(deviceId: string) { + return await this.#client.post(`https://iot.quasar.yandex.ru/m/v3/user/devices/${deviceId}/ding`); + } + + async getDevicesQuasarConfig() { + return await this.#client + .get("https://iot.quasar.yandex.ru/m/user/devices/quasar/configuration") + .then((res) => res.data.devices as Types.QuasarDeviceConfig[]); + } + + async runDeviceAction(id: string, actions: any[]) { + return await this.#client + .post(`https://iot.quasar.yandex.ru/m/user/devices/${id}/actions`, { actions }) + .then(() => {}); + } + + async getScenarios() { + return await this.#client + .get("https://iot.quasar.yandex.ru/m/user/scenarios") + .then((res) => res.data.scenarios as Types.Scenario[]); + } + + async getScenarioIcons() { + return await this.#client.get("https://iot.quasar.yandex.ru/m/user/scenarios/icons").then((res) => res.data); + } + + async createScenario(data: any) { + // status: 'ok', + // request_id: '9c465ddd-5e8d-4909-a14b-1f5d55c9e484', + // scenario_id: '5cc7b99c-5a55-434a-a6f3-554064d318db' + return await this.#client.post("https://iot.quasar.yandex.ru/m/v3/user/scenarios", data).then((res) => { + const status = res.data.status; + if (status !== "ok") throw new Error(res.data.message ?? res.data.code); + return res.data; + }); + } + + async editScenario(scenarioId: string, data: any) { + await this.#client.put(`https://iot.quasar.yandex.ru/m/user/scenarios/${scenarioId}`, data); + } + + async runScenarioAction(scenarioId: string) { + await this.#client.post(`https://iot.quasar.yandex.ru/m/user/scenarios/${scenarioId}/actions`); + } +} diff --git a/library/api/utils.ts b/library/api/utils.ts index eb5b23d..b3a728f 100644 --- a/library/api/utils.ts +++ b/library/api/utils.ts @@ -1,52 +1,59 @@ -import http from "http"; -import https from "https"; -import axios, { AxiosInstance, CreateAxiosDefaults } from "axios"; -import { YandexStorage } from "../storage.js"; +import http from "node:http"; +import https from "node:https"; +import axios, { type AxiosInstance, type CreateAxiosDefaults } from "axios"; +import type { YandexStorage } from "../storage.js"; -interface YandexAxiosInstance extends AxiosInstance { - lastRequest: number; +interface YandexClient extends AxiosInstance { + lastRequest: number; } -export const createInstance = (storage: YandexStorage, configCallback: (config: CreateAxiosDefaults) => CreateAxiosDefaults) => { - const client = axios.create(configCallback({ - withCredentials: true, - httpAgent: new http.Agent({ keepAlive: true }), - httpsAgent: new https.Agent({ keepAlive: true }), - headers: { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15", - "Accept": "*/*", - "Accept-Language": "ru", - "Accept-Encoding": "gzip, deflate, br" - } - })) as YandexAxiosInstance; +export const createInstance = ( + storage: YandexStorage, + configCallback: (config: CreateAxiosDefaults) => CreateAxiosDefaults, +) => { + const client = axios.create( + configCallback({ + withCredentials: true, + httpAgent: new http.Agent({ keepAlive: true }), + httpsAgent: new https.Agent({ keepAlive: true }), + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15", + Accept: "*/*", + "Accept-Language": "ru", + "Accept-Encoding": "gzip, deflate, br", + }, + }), + ) as YandexClient; - client.lastRequest = Date.now(); + client.lastRequest = Date.now(); - client.interceptors.request.use(async config => { - const delay = client.lastRequest + 200 - Date.now(); - delay > 0 && await new Promise(resolve => setTimeout(resolve, delay)); - client.lastRequest = Date.now(); + client.interceptors.request.use(async (config) => { + const delay = client.lastRequest + 200 - Date.now(); + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + client.lastRequest = Date.now(); - const cookies = await storage.getCookies(config.url || ""); - (cookies) && config.headers.set("Cookie", cookies); - return config; - }); + const address = config.baseURL || config.url || ""; + const cookies = await storage.getCookies(address); + cookies && config.headers.set("Cookie", cookies); + return config; + }); - client.interceptors.response.use( - async response => { - const url = response.config.url; - const cookies = response.headers["set-cookie"]; - (url && cookies) && await storage.setCookies(url, cookies); - return response; - }, - async error => { - if (axios.isAxiosError(error) && error.response?.status === 401) { - await storage.removeCookies(); - throw new Error("Требуется повторная авторизация"); - } - throw error; - } - ); + client.interceptors.response.use( + async (response) => { + const address = response.config.baseURL || response.config.url; + const cookies = response.headers["set-cookie"]; + if (address && cookies) await storage.setCookies(address, cookies); + return response; + }, + async (error) => { + if (axios.isAxiosError(error) && error.response?.status === 401) { + await storage.removeCookies(); + throw new Error("Требуется повторная авторизация"); + } + throw error; + }, + ); - return client; -}; \ No newline at end of file + return client; +}; diff --git a/library/client/home/devices/media.ts b/library/client/home/devices/media.ts new file mode 100644 index 0000000..0321326 --- /dev/null +++ b/library/client/home/devices/media.ts @@ -0,0 +1,291 @@ +import { randomUUID } from "node:crypto"; +import EventEmitter from "node:events"; +import { isIPv4 } from "node:net"; +import type { YandexAPI } from "../../../api"; +import type * as Types from "../../../typings"; +import { parseJson } from "../../../utils/json"; +import { ReconnectSocket } from "../../../utils/websocket"; +import type { YandexHomeUpdater } from "../updater"; + +enum Connection { + Cloud = 0, + Local = 1 +}; + +export enum AliceState { + Idle = "IDLE", + Listening = "LISTENING", + Speaking = "SPEAKING", +} + +export enum RepeatMode { + None = 1, + One = 2, + All = 3 +}; + +export enum ControlAction { + Click = "click_action", + Up = "go_up", + Down = "go_down", + Left = "go_left", + Right = "go_right" +} + +export class YandexMediaDevice extends EventEmitter { + #api: YandexAPI; + #updater: YandexHomeUpdater; + #connection: Connection; + #websocket: ReconnectSocket; + #conversationToken?: string; + + state: Types.GlagolState = { + aliceState: AliceState.Idle, + canStop: false, + hdmi: { + capable: false, + present: false + }, + playing: false, + timeSinceLastVoiceActivity: 1000, + volume: 0.5 + }; + + constructor(readonly id: string, api: YandexAPI, updater: YandexHomeUpdater) { + super(); + this.#api = api; + this.#updater = updater; + this.#connection = Connection.Cloud; + + this.#websocket = new ReconnectSocket({ + address: async () => { + const device = await this.#updater.getDevice(id); + const quasarInfo = device.quasar_info; + if (!quasarInfo) throw new Error("Нет информации об устройстве"); + + const glagolId = quasarInfo.device_id; + const glagolDevices = await this.#api.quasar.getAudioDevices(); + const glagolDevice = glagolDevices.find((x) => x.id === this.id); + + const networkInfo = glagolDevice?.glagol_info?.network_info; + if (!networkInfo) throw new Error("Нет информации о сети"); + + const address = networkInfo.ip_addresses.find((ip) => isIPv4(ip)); + if (!address) throw new Error("Нет IPv4 адреса"); + + this.#conversationToken = await this.#api.quasar.getGlagolToken(glagolId, quasarInfo.platform); + return `wss://${address}:${networkInfo.external_port}`; + }, + options: { rejectUnauthorized: false }, + heartbeat: 10, + message: { + transform: async (payload) => ({ + id: randomUUID(), + sentTime: Date.now(), + conversationToken: this.#conversationToken, + payload, + }), + encode: async (payload) => JSON.stringify(payload), + decode: async (message) => parseJson(message.toString()), + identify: async (payload, message) => + message.requestId === payload.id && message.requestSentTime === payload.sentTime, + } + }); + + this.#websocket.on("connect", async () => { + await this.#websocket.send({ command: "softwareVersion" }); + this.#connection = Connection.Local; + }); + + this.#websocket.on("message", (message: Types.GlagolMessage) => { + const currentState = { ...this.state, timeSinceLastVoiceActivity: 0 }; + const messageState = { ...message.state, timeSinceLastVoiceActivity: 0 }; + + if (JSON.stringify(currentState) !== JSON.stringify(messageState)) { + this.state = message.state; + this.emit("state", this.state); + } + }); + } + + async connect() { + await this.#websocket.connect(); + } + + async disconnect() { + await this.#websocket.disconnect(); + this.removeAllListeners(); + } + + async #commandQuasar(instance: string, value: unknown) { + await this.#api.quasar.runDeviceAction(this.id, [{ + type: "devices.capabilities.quasar.server_action", + state: { instance, value } + }]); + return ""; + } + + async #commandGlagol(command: string, args: Record = {}) { + return await this.#websocket + .send({ command, ...args }) + .then((response: Types.GlagolResponse) => { + if (!("vinsResponse" in response)) return ""; + const vins = response.vinsResponse; + const voiceText = vins.voice_response?.output_speech?.text; + const cardText = vins.response?.cards?.find((card) => card.type === "simple_text")?.text; + return voiceText || cardText || ""; + }); + } + + async #command(quasar?: [string, string?], glagol?: [string, Record?], volume?: number) { + const currentState = this.state; + let response = ""; + + if (volume !== undefined) { + await this.pause(); + await this.volumeSet(volume); + } + + if ((this.#connection === Connection.Cloud && quasar) || + (this.#connection === Connection.Local && quasar && !glagol)) { + const instance = quasar.length > 1 ? "phrase_action" : "text_action"; + response = await this.#commandQuasar(instance, quasar[0]); + + if (this.#connection === Connection.Local) { + await new Promise((resolve) => { + const listener = (state: Types.GlagolState) => { + if (state.aliceState !== AliceState.Idle) return; + this.removeListener("state", listener); + resolve(); + }; + + this.addListener("state", listener); + setTimeout(() => listener(this.state), 2_000); + }); + } + } + + if (this.#connection === Connection.Local && glagol) { + response = await this.#commandGlagol(glagol[0], glagol[1]); + } + + if (volume !== undefined) { + await this.volumeSet(currentState.volume); + if (currentState.playing) await this.play(); + } + + return response; + } + + async say(text: string, volume?: number) { + await this.#command([text, "tts"], undefined, volume); + } + + async send(text: string, volume?: number) { + await this.#command([text], ["sendText", { text }], volume); + } + + async play() { + if (this.#connection === Connection.Local && + this.state.playing === true) return; + + if (this.#connection === Connection.Cloud) { + this.state.playing = true; + this.emit("state", this.state); + } + + await this.#command(["играй"], ["play"]); + } + + async pause() { + if (this.#connection === Connection.Local && + this.state.playing === false) return; + + if (this.#connection === Connection.Cloud) { + this.state.playing = false; + this.emit("state", this.state); + } + + await this.#command(["стоп"], ["stop"]); + } + + async rewind(position: number) { + if (this.#connection === Connection.Local && + this.state.playerState?.progress === position) return; + + await this.#command(undefined, ["rewind", { position }]); + } + + async next() { + if (this.#connection === Connection.Cloud) { + this.state.playing = true; + this.emit("state", this.state); + } + + await this.#command(["следующий"], ["next"]); + } + + async prev() { + if (this.#connection === Connection.Cloud) { + this.state.playing = true; + this.emit("state", this.state); + } + + await this.#command(["предыдущий"], ["prev"]); + } + + async shuffle(enable: boolean) { + if (this.#connection === Connection.Local && + this.state.playerState?.entityInfo.shuffled === enable) return; + + await this.#command(["перемешай"], ["shuffle", { enable }], 0); + } + + async repeat(mode: RepeatMode) { + if (this.#connection === Connection.Local && + this.state.playerState?.entityInfo.repeatMode === RepeatMode[mode]) return; + + if (this.#connection === Connection.Cloud && mode === RepeatMode.None) + return await this.next(); + + await this.#command(["на повтор"], ["repeat", { mode }], 0); + } + + async volumeSet(value: number) { + const volume = Math.min(Math.max(value, 0), 1); + + if (this.#connection === Connection.Local && + this.state.volume === volume) return; + + if (this.#connection === Connection.Cloud) { + this.state.volume = volume; + this.emit("state", this.state); + } + + await this.#command([`громкость ${volume * 10}`], ["setVolume", { volume }]); + } + + async control(action: ControlAction) { + const actions: Record = { + click_action: "нажми", + go_up: "вверх", + go_down: "вниз", + go_left: "влево", + go_right: "вправо" + }; + + await this.#command([actions[action]], ["control", { action }]); + } + + async home() { + await this.#command(["домой"]); + } + + async back() { + await this.#command(["назад"]); + } + + async power(state: boolean) { + await this.#command([`${state ? "включи" : "выключи"} телевизор`], undefined, 0); + } +} \ No newline at end of file diff --git a/library/client/home/devices/speaker.ts b/library/client/home/devices/speaker.ts deleted file mode 100644 index 6fc55aa..0000000 --- a/library/client/home/devices/speaker.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { randomUUID } from "crypto"; -import EventEmitter from "events"; -import { isIPv4 } from "net"; -import { YandexAPI } from "../../../api/index.js"; -import { YandexHomeUpdater } from "../updater.js"; -import { YandexSpeakerState, YandexSpeakerVinsResponse } from "../typings.js"; -import { ReconnectSocket } from "../../../utils/websocket.js"; -import { strictJsonParse } from "../../../utils/json.js"; - -enum AliceState { - Idle = "IDLE", - Speaking = "SPEAKING" -}; - -enum Connection { - Local = 0, - Cloud = 1 -}; - -type CommandParams = { - cloud?: [string, string?]; - local?: [string, Object?]; - volume?: number; -}; - -export class YandexSpeaker extends EventEmitter { - state: Partial; - connection: Connection; - - private websocket: ReconnectSocket; - private token?: string; - - constructor(readonly id: string, private api: YandexAPI, private updater: YandexHomeUpdater) { - super(); - this.state = { volume: 0.5, playing: false }; - this.connection = Connection.Cloud; - - this.websocket = new ReconnectSocket({ - address: async () => { - const device = await this.updater.getDevice(id); - const deviceInfo = device.quasar_info; - - const speakerId = deviceInfo.device_id; - const speakers = await this.api.quasar.getAudioDevices() as Array; - const speaker = speakers.find(s => s.id === this.id); - - if (!speaker.glagol_info.network_info) { - this.connection = Connection.Cloud; - throw new Error("Нет локального управления"); - } - - const speakerAddress = speaker.glagol_info.network_info.ip_addresses.find((ip: string) => isIPv4(ip)); - const speakerPort = speaker.glagol_info.network_info.external_port; - this.token = await this.api.quasar.getGlagolToken(speakerId, deviceInfo.platform); - return `wss://${speakerAddress}:${speakerPort}`; - }, - options: { rejectUnauthorized: false }, - heartbeat: 10, - message: { - transform: async payload => ({ - id: randomUUID(), sentTime: Date.now(), - conversationToken: this.token, - payload - }), - encode: async payload => JSON.stringify(payload), - decode: async message => strictJsonParse(message.toString()), - identify: async (payload, message) => - message.requestId === payload.id && - message.requestSentTime === payload.sentTime - } - }); - - this.websocket.on("connect", async () => { - await this.websocket.send({ command: "softwareVersion" }); - this.connection = Connection.Local; - }); - - this.websocket.on("message", message => { - const state = message.state; - if (JSON.stringify(this.state) === JSON.stringify(state)) return; - this.state = state; - this.emit("state", this.state); - }); - } - - async connect() { - await this.websocket.connect(); - } - - async disconnect() { - await this.websocket.disconnect(); - } - - async destroy() { - await this.disconnect(); - this.removeAllListeners(); - } - - private async command(params: Omit) { - const cloudAction = async () => { - if (!params.cloud) return; - await this.api.quasar.runDeviceAction(this.id, [{ - type: "devices.capabilities.quasar.server_action", - state: { - instance: params.cloud[1] ?? "text_action", - value: params.cloud[0] - } - }]); - return ""; - }; - - if (!params.local || this.connection === Connection.Cloud) - return await cloudAction(); - - return await this.websocket.send({ command: params.local[0], ...(params.local[1] || {}) }) - .then(res => { - if (!res.vinsResponse) return; - const vinsResponse = res.vinsResponse as YandexSpeakerVinsResponse["vinsResponse"]; - const speechResponse = vinsResponse.voice_response?.output_speech?.text; - const cardsResponse = vinsResponse.response?.cards?.find(card => card.type === "simple_text"); - return speechResponse || cardsResponse && cardsResponse.text; - }) - .catch(cloudAction); - } - - private async commandWithVolume(params: CommandParams) { - return new Promise(async (resolve, reject) => { - try { - let response: any; - const { volume, ...command } = params; - const currentVolume = this.state.volume!; - const currentPlaying = this.state.playing!; - const bringBack = async () => { - await this.volumeSet(currentVolume); - if (currentPlaying) await this.mediaPlay(); - resolve(response); - }; - - await this.mediaPause(); - await this.volumeSet(volume!); - response = await this.command(command); - - // В облачном режиме все команды идут по очереди, поэтому сразу возвращаем - if (this.connection !== Connection.Local) - return await bringBack(); - - // В локальном режиме ждем, когда Алиса договорит - let aliceState: string; - const handleState = async (state: YandexSpeakerState) => { - if ((aliceState === AliceState.Speaking && state.aliceState !== aliceState) || - (aliceState === AliceState.Idle && state.aliceState === AliceState.Idle)) { - this.off("state", handleState); - await bringBack(); - } - aliceState = state.aliceState; - }; - this.on("state", handleState); - } catch (e) { reject(e) } - }); - } - - async actionSay(message: string, volume?: number) { - volume = volume ?? this.state.volume; - - await this.commandWithVolume({ - cloud: [message, "phrase_action"], - volume - }); - } - - async actionRun(command: string, volume?: number) { - volume = volume ?? this.state.volume; - - return await this.commandWithVolume({ - cloud: [command], - local: ["sendText", { text: command }], - volume - }); - } - - async controlClick() { - await this.command({ - cloud: ["нажми"], - local: ["control", { action: "click_action" }] - }); - } - - async controlUp() { - await this.command({ - cloud: ["вверх"], - local: ["control", { action: "go_up" }] - }); - } - - async controlDown() { - await this.command({ - cloud: ["вниз"], - local: ["control", { action: "go_down" }] - }); - } - - async controlLeft() { - await this.command({ - cloud: ["влево"], - local: ["control", { action: "go_left" }] - }); - } - - async controlRight() { - await this.command({ - cloud: ["вправо"], - local: ["control", { action: "go_right" }] - }); - } - - async controlHome() { - await this.command({ - cloud: ["домой"] - }); - } - - async controlBack() { - await this.command({ - cloud: ["назад"] - }); - } - - async controlPower(enable: boolean) { - await this.commandWithVolume({ - cloud: [(enable && "включи" || "выключи") + "телевизор"], - volume: 0 - }); - } - - async mediaPlay() { - if (this.state.playing) return; - - await this.command({ - cloud: ["играй"], - local: ["play"] - }); - - // Облачный фоллбэк - this.state.playing = true; - this.emit("state", this.state); - } - - async mediaPause() { - if (!this.state.playing) return; - - await this.command({ - cloud: ["стоп"], - local: ["stop"] - }); - - // Облачный фоллбэк - this.state.playing = false; - this.emit("state", this.state); - } - - async mediaRewind(position: number) { - await this.command({ - local: ["rewind", { position }] - }); - } - - async mediaNext() { - await this.command({ - cloud: ["следующий"], - local: ["next"] - }); - - // Облачный фоллбэк - this.state.playing = true; - this.emit("state", this.state); - } - - async mediaPrev() { - await this.command({ - cloud: ["предыдущий"], - local: ["prev"] - }); - - // Облачный фоллбэк - this.state.playing = true; - this.emit("state", this.state); - } - - async musicShuffle(enable: boolean) { - if (this.state.playerState?.entityInfo.shuffled === enable) return; - - await this.commandWithVolume({ - cloud: ["перемешай"], - local: ["shuffle", { enable }], - volume: 0 - }); - - // Облачный фоллбэк - this.state.playerState = this.state.playerState || {} as any; - this.state.playerState!.entityInfo = this.state.playerState!.entityInfo || {}; - this.state.playerState!.entityInfo!.shuffled = enable; - this.emit("state", this.state); - } - - async musicRepeat(repeatMode: "none" | "one" | "all") { - const modes = { "none": 1, "one": 2, "all": 3 }; - const mode = modes[repeatMode]; - - // В облачном режиме переключаемся на следующий трек, - // если режим повтора 'none' - if (this.connection !== Connection.Local && mode === 1) - return await this.mediaNext(); - - await this.commandWithVolume({ - cloud: ["на повтор"], - local: ["repeat", { mode }], - volume: 0 - }); - } - - async volumeSet(volume: number) { - volume = Math.min(Math.max(volume, 0), 1); - if (volume === this.state.volume) return; - - await this.command({ - cloud: [`громкость ${volume * 10}`], - local: ["setVolume", { volume }] - }); - - // Облачный фоллбэк - this.state.volume = volume; - this.emit("state", this.state); - } - - async volumeUp(step: number = 0.1) { - const volume = this.state.volume! + step; - await this.volumeSet(volume); - } - - async volumeDown(step: number = 0.1) { - const volume = this.state.volume! - step; - await this.volumeSet(volume); - } -} \ No newline at end of file diff --git a/library/client/home/index.ts b/library/client/home/index.ts index 5de5a5b..48caa92 100644 --- a/library/client/home/index.ts +++ b/library/client/home/index.ts @@ -1,20 +1,22 @@ -import { YandexAPI } from "../../api/index.js"; -import { YandexSpeaker } from "./devices/speaker.js"; +import type { YandexAPI } from "../../api/index.js"; +import { YandexMediaDevice } from "./devices/media.js"; import { YandexHomeUpdater } from "./updater.js"; export class YandexHome { - readonly updater: YandexHomeUpdater; + #api: YandexAPI; + readonly updater: YandexHomeUpdater; - constructor(private api: YandexAPI) { - this.updater = new YandexHomeUpdater(this.api); - } + constructor(api: YandexAPI) { + this.#api = api; + this.updater = new YandexHomeUpdater(this.#api); + } - async destroy() { - await this.updater.destroy(); - } + async disconnect() { + await this.updater.disconnect(); + } - async createSpeaker(id: string) { - const speaker = new YandexSpeaker(id, this.api, this.updater); - return speaker; - } -} \ No newline at end of file + async createMediaDevice(id: string) { + const media = new YandexMediaDevice(id, this.#api, this.updater); + return media; + } +} diff --git a/library/client/home/typings.ts b/library/client/home/typings.ts deleted file mode 100644 index 427cd46..0000000 --- a/library/client/home/typings.ts +++ /dev/null @@ -1,104 +0,0 @@ -export type YandexSpeakerMessage = { - experiments: Record; - extra: { - appState: string; - environmentState: string; - watchedVideoState: string; - }; - id: string; - sentTime: number; - state: YandexSpeakerState; - supported_features: string[]; - unsupported_features: string[]; -}; - -export type YandexSpeakerResponse = YandexSpeakerMessage & { - processingTime: number; - requestId: string; - requestSentTime: number; - status: "SUCCESS"; -} - -export type YandexSpeakerVinsResponse = YandexSpeakerResponse & { - errorCode: string; - errorText: string; - errorTextLang: string; - vinsResponse: { - header: { - dialog_id: string; - request_id: string; - response_id: string; - sequence_number: number; - }; - response: { - is_streaming?: boolean; - cards?: Array<{ - card_id: string; - text: string; - type: string; - }>; - directives?: Array; - suggest: { - items: Array<{ - directives: Array; - title: string; - type: string; - }>; - }; - }; - voice_response: { - has_voice_response: boolean; - output_speech: { - text: string; - }; - should_listen: boolean; - }; - }; -}; - -export type YandexSpeakerState = { - aliceState: "IDLE" | "LISTENING" | "SPEAKING"; - canStop: boolean; - hdmi: { - capable: boolean; - present: boolean; - }; - controlState: any; - playerState: { - duration: number; - entityInfo: { - description: string; - id: string; - next?: any; - prev?: any; - repeatMode?: string; - shuffled?: boolean; - type: string; - }; - extra: { - coverURI: string; - requestID: string; - stateType: string; - } | null; - hasNext: boolean; - hasPause: boolean; - hasPlay: boolean; - hasPrev: boolean; - hasProgressBar: boolean; - id: string; - liveStreamText: string; - playerType: string; - playlistDescription: string; - playlistId: string; - playlistPuid: string; - playlistType: string; - progress: number; - showPlayer: boolean; - subtitle: string; - title: string; - type: string; - }; - playing: boolean; - timeSinceLastVoiceActivity: boolean; - volume: number; -}; \ No newline at end of file diff --git a/library/client/home/updater.ts b/library/client/home/updater.ts index 5eea583..82585e9 100644 --- a/library/client/home/updater.ts +++ b/library/client/home/updater.ts @@ -1,158 +1,188 @@ -import EventEmitter from "events"; -import { YandexAPI } from "../../api/index.js"; +import EventEmitter from "node:events"; +import type { YandexAPI } from "../../api/index.js"; +import type * as Types from "../../typings"; +import { parseJson } from "../../utils/json.js"; import { ReconnectSocket } from "../../utils/websocket.js"; -import { strictJsonParse } from "../../utils/json.js"; + +type UpdateScenarios = { + operation: "update_scenario_list"; + source: "create_scenario_launch" | "update_scenario_launch"; + scenarios: Types.Scenario[]; + scheduled_scenarios: any[]; +}; + +type UpdateDevices = { + operation: "update_device_list"; + households: Types.HouseholdV3[]; +}; + +type UpdateDevicesStates = { + operation: "update_states"; + update_groups: null; + update_multidevices: null; +} & ( + | { + source: "query"; + updated_devices: Types.DeviceStateQuery[]; + } + | { + source: "action"; + updated_devices: Types.DeviceStateAction[]; + } + | { + source: "callback"; + updated_devices: Types.DeviceStateCallback[]; + } +); + +type UpdateMessage = UpdateScenarios | UpdateDevices | UpdateDevicesStates; export class YandexHomeUpdater extends EventEmitter { - private websocket: ReconnectSocket; - private devices: Record; - private scenarios: Array; - - constructor(private api: YandexAPI) { - super(); - this.devices = {}; - this.scenarios = []; - - this.websocket = new ReconnectSocket({ - address: async () => { - const response = await this.api.quasar.getDevices(); - this.updateDevices(response.households); - return response.updates_url; - }, - heartbeat: 70, - message: { - decode: async message => { - const json = strictJsonParse(message.toString()); - const data = strictJsonParse(json.message); - return { operation: json.operation, ...data }; - } - } - }); - - this.websocket.on("message", async message => { - switch (message.operation) { - case "update_device_list": return await this.handleDevices(message); - case "update_scenario_list": return await this.handleScenarios(message); - case "update_states": return await this.handleStates(message); - } - }); - } - - async connect() { - await this.websocket.connect(); - } - - async disconnect() { - await this.websocket.disconnect(); - } - - async destroy() { - await this.disconnect(); - this.removeAllListeners(); - } - - async getDevices() { - await this.connect(); - return this.devices; - } - - async getDevice(id: string) { - const devices = await this.getDevices(); - return devices[id]; - } - - async getDevicesByType(type: string) { - const devices = await this.getDevices(); - return Object.values(devices) - .filter(device => device.type === type); - } - - async getDevicesByPlatform(platform: string) { - const devices = await this.getDevices(); - return Object.values(devices) - .filter(device => device.quasar_info?.platform === platform); - } - - async getScenarios() { - await this.connect(); - if (!this.scenarios.length) { - const scenarios = await this.api.quasar.getScenarios(); - await this.updateScenarios(scenarios); - } - return this.scenarios; - } - - async getScenarioByTrigger(trigger: string) { - const scenarios = await this.getScenarios(); - return scenarios.find(scenario => scenario.trigger === trigger); - } - - async getScenarioByAction(action: string) { - const scenarios = await this.getScenarios(); - return scenarios.find(scenario => scenario.action.value === action); - } - - private async handleDevices(data: any) { - this.updateDevices(data.households); - this.emit("devices", this.devices); - } - - private async handleScenarios(data: any) { - this.updateScenarios(data.scenarios); - this.emit("scenarios", data); - } - - private async handleStates(data: any) { - this.emit("states", data); - - // Триггер сценария - const devices = data.updated_devices as any[]; - const devicesPromises = devices.map(async device => { - if (!device.hasOwnProperty("capabilities") || - device.capabilities.length !== 1) return; - - const capability = device.capabilities[0]; - if (!capability.hasOwnProperty("state") || - capability.type !== "devices.capabilities.quasar.server_action") return; - - const scenario = await this.getScenarioByAction(capability.state.value).catch(console.error); - scenario && this.emit("scenario_run", scenario); - }); - await Promise.all(devicesPromises); - } - - private updateDevices(households: any[]) { - this.devices = households.reduce((result, household) => { - const devices = household.all; - devices.map((device: any) => result[device.id] = device); - return result; - }, {}); - } - - private async updateScenarios(scenarios: any[]) { - if (!scenarios.length) return; - - const scenarioIds = scenarios.map(scenario => scenario.id); - const scenarioPromises = scenarioIds.map(async scenarioId => { - const res = await this.api.request(`https://iot.quasar.yandex.ru/m/user/scenarios/${scenarioId}/edit`); - return res.data.scenario; - }); - scenarios = await Promise.all(scenarioPromises); - - this.scenarios = scenarios - .map(scenario => ({ - name: scenario.name, - trigger: scenario.triggers[0]?.value, - action: { - type: scenario.steps[0]?.parameters?.launch_devices[0]?.capabilities[0]?.state?.instance || - scenario.steps[0]?.parameters?.requested_speaker_capabilities[0]?.state?.instance, - value: scenario.steps[0]?.parameters?.launch_devices[0]?.capabilities[0]?.state?.value || - scenario.steps[0]?.parameters?.requested_speaker_capabilities[0]?.state?.value - }, - icon: scenario.icon_url, - device_id: scenario.steps[0]?.parameters?.launch_devices[0]?.id, - id: scenario.id - })) - .filter(scenario => ["text_action", "phrase_action"].includes(scenario.action.type)); - } -} \ No newline at end of file + #websocket: ReconnectSocket; + #devices: Record = {}; + #scenarios: any[] = []; + + constructor(private api: YandexAPI) { + super(); + this.#websocket = new ReconnectSocket({ + address: async () => { + const response = await this.api.quasar.getDevices(); + this.#updateDevices(response.households); + return response.updates_url; + }, + heartbeat: 70, + message: { + decode: async (message) => { + const json = parseJson>(message.toString()); + const data = parseJson>(json.message as string); + return { operation: json.operation as string, ...data }; + }, + }, + }); + + this.#websocket.on("message", async (message: UpdateMessage) => { + switch (message.operation) { + case "update_scenario_list": + return await this.#handleScenarios(message); + case "update_device_list": + return await this.#handleDevices(message); + case "update_states": + return await this.#handleStates(message); + } + }); + } + + async disconnect() { + await this.#websocket.disconnect(); + this.removeAllListeners(); + } + + async #handleScenarios(message: UpdateScenarios) { + this.#updateScenarios(message.scenarios); + this.emit("scenarios", message); + } + + async #handleDevices(message: UpdateDevices) { + this.#updateDevices(message.households); + this.emit("devices", this.#devices); + } + + async #handleStates(message: UpdateDevicesStates) { + this.emit("states", message); + + if (message.source === "action") { + await Promise.all( + message.updated_devices.map(async (device) => { + await Promise.all( + device.capabilities.map(async (capability) => { + if (capability.type !== "devices.capabilities.quasar.server_action") return; + const scenario = await this.getScenarioByAction(capability.state.value as string).catch(console.error); + scenario && this.emit("scenario_run", scenario); + }), + ); + }), + ); + } + } + + async getDevices() { + await this.#websocket.connect(); + return this.#devices; + } + + async getDevice(id: string) { + const devices = await this.getDevices(); + return devices[id]; + } + + async getDevicesByType(type: string) { + const devices = await this.getDevices(); + return Object.values(devices).filter((device) => device.type === type); + } + + async getDevicesByPlatform(platform: string) { + const devices = await this.getDevices(); + return Object.values(devices).filter((device) => device.quasar_info?.platform === platform); + } + + async getScenarios() { + await this.#websocket.connect(); + + if (!this.#scenarios.length) { + const scenarios = await this.api.quasar.getScenarios(); + await this.#updateScenarios(scenarios); + } + + return this.#scenarios; + } + + async getScenarioByTrigger(trigger: string) { + const scenarios = await this.getScenarios(); + return scenarios.find((scenario) => scenario.trigger === trigger); + } + + async getScenarioByAction(action: string) { + const scenarios = await this.getScenarios(); + return scenarios.find((scenario) => scenario.action.value === action); + } + + #updateDevices(households: Types.HouseholdV3[]) { + households.map((household) => { + const devices = household.all; + devices.map((device) => { + this.#devices[device.id] = device; + }); + }); + } + + async #updateScenarios(scenarios: Types.Scenario[]) { + if (!scenarios.length) return; + + const scenarioIds = scenarios.map((scenario) => scenario.id); + const scenarioDetails = await Promise.all( + scenarioIds.map(async (scenarioId) => { + const address = `https://iot.quasar.yandex.ru/m/user/scenarios/${scenarioId}/edit`; + const response = await this.api.request(address); + return response.data.scenario; + }), + ); + + this.#scenarios = scenarioDetails + .map((scenario) => ({ + name: scenario.name, + trigger: scenario.triggers[0]?.value, + action: { + type: + scenario.steps[0]?.parameters?.launch_devices[0]?.capabilities[0]?.state?.instance || + scenario.steps[0]?.parameters?.requested_speaker_capabilities[0]?.state?.instance, + value: + scenario.steps[0]?.parameters?.launch_devices[0]?.capabilities[0]?.state?.value || + scenario.steps[0]?.parameters?.requested_speaker_capabilities[0]?.state?.value, + }, + icon: scenario.icon_url, + device_id: scenario.steps[0]?.parameters?.launch_devices[0]?.id, + id: scenario.id, + })) + .filter((scenario) => ["text_action", "phrase_action"].includes(scenario.action.type)); + } +} diff --git a/library/index.ts b/library/index.ts index d2eb3cf..4d62dbc 100644 --- a/library/index.ts +++ b/library/index.ts @@ -1,15 +1,15 @@ import { YandexAPI } from "./api/index.js"; import { YandexHome } from "./client/home/index.js"; -import { type YandexStorageHandlers, YandexStorage } from "./storage.js"; +import { YandexStorage, type YandexStorageHandlers } from "./storage.js"; export class Yandex { - readonly storage: YandexStorage; - readonly api: YandexAPI; - readonly home: YandexHome; + readonly storage: YandexStorage; + readonly api: YandexAPI; + readonly home: YandexHome; - constructor(storageHandlers: YandexStorageHandlers) { - this.storage = new YandexStorage(storageHandlers); - this.api = new YandexAPI(this.storage); - this.home = new YandexHome(this.api); - } -} \ No newline at end of file + constructor(storageHandlers: YandexStorageHandlers) { + this.storage = new YandexStorage(storageHandlers); + this.api = new YandexAPI(this.storage); + this.home = new YandexHome(this.api); + } +} diff --git a/library/storage.ts b/library/storage.ts index 59d5624..8d95146 100644 --- a/library/storage.ts +++ b/library/storage.ts @@ -1,91 +1,94 @@ import { CookieJar } from "tough-cookie"; export type YandexStorageHandlers = { - get: () => PromiseLike; - set: (content: YandexStorageContent) => PromiseLike; -} + get: () => PromiseLike; + set: (content: StorageContent) => PromiseLike; +}; -export type YandexStorageContent = { - cookieJar?: string; - tokens?: Record; -} +type StorageContent = { + cookieJar?: string; + tokens?: Record; +}; -export type YandexStorageToken = { - accessToken: string; - expiresAt: number; -} +type StorageToken = { + accessToken: string; + expiresAt: number; +}; export class YandexStorage { - private content?: Omit & { - cookieJar: CookieJar; - }; - - constructor(private handlers: YandexStorageHandlers) {} - - private async getContent() { - if (this.content) return this.content; - - const content = await this.handlers.get(); - const cookieJar = await CookieJar.deserialize(content.cookieJar || JSON.stringify({ cookies: [] })); - return this.content = { ...content, cookieJar }; - } - - private async setContent() { - if (!this.content) return; - - const cookieJar = JSON.stringify(this.content.cookieJar.toJSON()); - const content = { ...this.content, cookieJar }; - await this.handlers.set(content); - } - - async getCookies(address: string) { - const cookieJar = (await this.getContent()).cookieJar; - return await cookieJar.getCookieString(address); - } - - async setCookies(address: string, cookies: string[]) { - const cookieJar = (await this.getContent()).cookieJar; - await Promise.all(cookies.map(item => cookieJar.setCookie(item, address))); - await this.setContent(); - } - - async removeCookies() { - const content = await this.getContent(); - content.cookieJar = new CookieJar(); - await this.setContent(); - } - - async getTokens() { - const content = await this.getContent(); - return content.tokens || {}; - } - - async getToken(clientId: string) { - const tokens = await this.getTokens(); - const token = tokens[clientId] as YandexStorageToken; - if (token === undefined) throw new Error("Нет токена"); - - const currentTime = Math.round(Date.now() / 1000); - if (token.expiresAt - currentTime < 0) - throw new Error("Токен устарел"); - - return token.accessToken; - } - - async setToken(clientId: string, token: any) { - const accessToken = token.access_token as string; - const expiresIn = token.expires_in as number; - - if (accessToken === undefined || expiresIn === undefined) - throw new Error("Недействительный токен"); - - const currentTime = Math.round(Date.now() / 1000); - const expiresAt = currentTime + expiresIn; - const content = await this.getContent(); - content.tokens = content.tokens || {}; - content.tokens[clientId] = { accessToken, expiresAt }; - - await this.setContent(); - return accessToken; - } -} \ No newline at end of file + #handlers: YandexStorageHandlers; + #content?: Omit & { cookieJar: CookieJar }; + + constructor(handlers: YandexStorageHandlers) { + this.#handlers = handlers; + } + + private async getContent() { + if (this.#content) return this.#content; + + const content = await this.#handlers.get(); + const cookieJarStr = content.cookieJar || JSON.stringify({ cookies: [] }); + const cookieJar = await CookieJar.deserialize(cookieJarStr); + this.#content = { ...content, cookieJar }; + + return this.#content; + } + + private async setContent() { + if (!this.#content) return; + + const cookieJar = JSON.stringify(this.#content.cookieJar.toJSON()); + const content = { ...this.#content, cookieJar }; + await this.#handlers.set(content); + } + + async getCookies(address: string) { + const cookieJar = (await this.getContent()).cookieJar; + return await cookieJar.getCookieString(address); + } + + async setCookies(address: string, cookies: string[]) { + const cookieJar = (await this.getContent()).cookieJar; + await Promise.all(cookies.map((item) => cookieJar.setCookie(item, address))); + await this.setContent(); + } + + async removeCookies() { + const content = await this.getContent(); + content.cookieJar = new CookieJar(); + await this.setContent(); + } + + async getTokens() { + const content = await this.getContent(); + return content.tokens || {}; + } + + async getToken(clientId: string) { + const tokens = await this.getTokens(); + const token = tokens[clientId]; + const currentTime = Math.round(Date.now() / 1000); + + if (token === undefined) throw new Error("Нет токена"); + if (token.expiresAt - currentTime < 0) throw new Error("Токен устарел"); + + return token.accessToken; + } + + async setToken(clientId: string, token: { access_token: string; expires_in: number }) { + const accessToken = token.access_token; + const expiresIn = token.expires_in; + + if (accessToken === undefined || expiresIn === undefined) throw new Error("Недействительный токен"); + + const currentTime = Math.round(Date.now() / 1000); + const expiresAt = currentTime + expiresIn; + + const content = await this.getContent(); + content.tokens = content.tokens || {}; + content.tokens[clientId] = { accessToken, expiresAt }; + + await this.setContent(); + return accessToken; + } +} diff --git a/library/typings/alice/alarm.ts b/library/typings/alice/alarm.ts new file mode 100644 index 0000000..4dd1a87 --- /dev/null +++ b/library/typings/alice/alarm.ts @@ -0,0 +1,17 @@ +import type { DayOfWeek } from "."; + +export type Alarm = { + device_id: string; // "XW000000000000000000000000000000" + alarm_id: string; // "00000000-00000000-00000000-00000000" + enabled: boolean; // true + time: string; // "01:23" +} & ( + | { + date?: string; // "2025-01-23" + } + | { + recurring?: { + days_of_week: DayOfWeek; + }; + } +); diff --git a/library/typings/alice/index.ts b/library/typings/alice/index.ts new file mode 100644 index 0000000..4e0679d --- /dev/null +++ b/library/typings/alice/index.ts @@ -0,0 +1,4 @@ +export type DayOfWeek = Array<"Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday">; + +export * from "./alarm"; +export * from "./reminder"; diff --git a/library/typings/alice/reminder.ts b/library/typings/alice/reminder.ts new file mode 100644 index 0000000..afefbf3 --- /dev/null +++ b/library/typings/alice/reminder.ts @@ -0,0 +1,29 @@ +import type { DayOfWeek } from "."; + +export type Reminder = { + id: string; // "00000000-00000000-00000000-00000000" + text: string; // "Текст напоминания" + properties: any; + play_settings: { + send_push: { + state: "Enabled" | "Disabled"; + }; + play_location?: { + selected_devices: { + selected_device_ids: string[]; // ["XW000000000000000000000000000000"] + }; + }; + }; + trigger_policy: + | { + single_trigger_policy: { + datetime: string; // "2025-01-23T01:23:45" + }; + } + | { + weekdays_trigger_policy: { + time: string; // "01:23:45" + day_of_week: DayOfWeek; + }; + }; +}; diff --git a/library/typings/home/device.ts b/library/typings/home/device.ts new file mode 100644 index 0000000..638186f --- /dev/null +++ b/library/typings/home/device.ts @@ -0,0 +1,59 @@ +import type { QuasarInfo } from "./services/quasar"; + +export type DeviceV3 = { + id: string; + name: string; + type: string; + icon_url: string; + capabilities: CapabilityV3[]; + properties: PropertyV3[]; + item_type: string; + skill_id: string; + quasar_info?: QuasarInfo; + room_name: string; + status_info: { + status: string; + reportable?: boolean; + updated?: number; + changed?: number; + }; + state: string; + created: string; + parameters: { + device_info: { + manufacturer?: string; + model?: string; + hw_version?: string; + sw_version?: string; + }; + }; +}; + +export type CapabilityV3 = { + type: string; + retrievable: boolean; + reportable: boolean; + parameters: Record; + state: { + instance: string; + value: unknown; + }; + can_be_deferred: boolean; +}; + +export type PropertyV3 = { + type: string; + retrievable: boolean; + reportable: boolean; + parameters: { + instance: string; + name: string; + unit: string; + }; + state: { + percent: number | null; + status: string | null; + value: number; + }; + last_updated: string; +}; diff --git a/library/typings/home/deviceState.ts b/library/typings/home/deviceState.ts new file mode 100644 index 0000000..7b4ddc0 --- /dev/null +++ b/library/typings/home/deviceState.ts @@ -0,0 +1,37 @@ +import type { CapabilityV3, PropertyV3 } from "./device"; + +export type DeviceStateQuery = { + id: string; + state: "online"; + status_info: { + status: "online"; + updated: number; + changed: number; + }; + capabilities: CapabilityV3[]; + properties: PropertyV3[]; +}; + +export type DeviceStateAction = { + id: string; + state: "online"; + status_info: Record; + capabilities: CapabilityV3[]; +}; + +export type DeviceStateCallback = { + id: string; + state: "online"; + status_info: { + status: "online"; + reportable: boolean; + updated: number; + changed: number; + }; + capabilities: CapabilityV3[]; + properties: Array< + PropertyV3 & { + state_changed_at: string; + } + >; +}; diff --git a/library/typings/home/index.ts b/library/typings/home/index.ts new file mode 100644 index 0000000..71dd5c3 --- /dev/null +++ b/library/typings/home/index.ts @@ -0,0 +1,33 @@ +import type { DeviceV3 } from "./device"; + +export * from "./services/glagol"; +export * from "./services/quasar"; +export * from "./device"; +export * from "./deviceState"; +export * from "./scenario"; + +export type HouseholdV3 = { + id: string; + name: string; + type: string; + location: { + address: string; + short_address: string; + }; + is_current: boolean; + aliases: string[]; + rooms: RoomV3[]; + all: DeviceV3[]; + all_background_image: { + id: string; + }; +}; + +export type RoomV3 = { + id: string; + name: string; + items: DeviceV3[]; + background_image: { + id: string; + }; +}; diff --git a/library/typings/home/scenario.ts b/library/typings/home/scenario.ts new file mode 100644 index 0000000..18fc25a --- /dev/null +++ b/library/typings/home/scenario.ts @@ -0,0 +1,48 @@ +type SpeakerCapability = { + type: string; + retrievable: boolean; + parameters: { + instance: "text_action" | "phrase_action"; + }; + state: { + instance: string; + value: string; + }; +}; + +export type Scenario = { + id: string; + name: string; + icon: string; + icon_url: string; + executable: boolean; + devices: string[]; + triggers: Array<{ + trigger: { + type: string; + value: string; + }; + type: string; + value: string; + }>; + steps: Array<{ + type: string; + parameters: { + items?: Array<{ + id: string; + type: string; + }>; + launch_devices?: Array<{ + id: string; + capabilities: Array<{ + state: { + value: unknown; + }; + }>; + }>; + requested_speaker_capabilities?: SpeakerCapability[]; + }; + }>; + is_active: boolean; + created: string; +}; diff --git a/library/typings/home/services/glagol.ts b/library/typings/home/services/glagol.ts new file mode 100644 index 0000000..fc94635 --- /dev/null +++ b/library/typings/home/services/glagol.ts @@ -0,0 +1,167 @@ +import type { QuasarConfig } from "./quasar"; + +export type GlagolDevice = { + activation_code: string; + activation_region: string; + config: QuasarConfig; + glagol: { + security: { + server_certificate: string; + server_private_key: string; + }; + }; + id: string; + name: string; + networkInfo?: { + external_port: number; + ip_addresses: string[]; + mac_addresses: string[]; + ts: number; + wifi_ssid?: string; + }; + platform: string; + promocode_activated: boolean; + tags: string[]; +}; + +export type GlagolAudioInfo = { + id: string; + name: string; + aliases: string[]; + type: string; + external_id: string; + skill_id: string; + household_id: string; + room: string; + groups: any[]; + capabilities: any[]; + properties: any[]; + quasar_info: { + device_id: string; + platform: string; + device_color: string; + }; + voiceprint: { + status: string; + method: string; + }; + glagol_info: { + server_certificate: string; + network_info: { + ts: number; + ip_addresses: string[]; + mac_addresses: string[]; + external_port: number; + wifi_ssid: string; + }; + }; +}; + +export type GlagolMessage = { + id: string; + sentTime: number; + state: GlagolState; + extra: { + appState: string; + environmentState: string; + watchedVideoState: string; + }; + experiments: Record; + supported_features: string[]; + unsupported_features: string[]; +}; + +export type GlagolState = { + aliceState: string; + canStop: boolean; + hdmi: { + capable: boolean; + present: boolean; + }; + controlState?: any; + playerState?: GlagolPlayerState; + playing: boolean; + timeSinceLastVoiceActivity: number; + volume: number; +}; + +export type GlagolPlayerState = { + duration: number; + entityInfo: { + description: string; + id: string; + next?: any; + prev?: any; + repeatMode?: string; + shuffled?: boolean; + type: string; + }; + extra: Record | { + coverURI: string; + requestID: string; + stateType: string; + }; + hasNext: boolean; + hasPause: boolean; + hasPlay: boolean; + hasPrev: boolean; + hasProgressBar: boolean; + id: string; + liveStreamText: string; + playerType: string; + playlistDescription: string; + playlistId: string; + playlistPuid: string; + playlistType: string; + progress: number; + showPlayer: boolean; + subtitle: string; + title: string; + type: string; +}; + +export type GlagolResponse = GlagolCommandResponse | GlagolVinsResponse; + +export type GlagolCommandResponse = GlagolMessage & { + requestId: string; + requestSentTime: number; + processingTime: number; + status: "SUCCESS"; +}; + +export type GlagolVinsResponse = GlagolCommandResponse & { + errorCode: string; + errorText: string; + errorTextLang: string; + vinsResponse: { + header: { + dialog_id: string; + request_id: string; + response_id: string; + sequence_number: number; + }; + response: { + is_streaming?: boolean; + cards?: Array<{ + card_id: string; + text: string; + type: string; + }>; + directives?: any[]; + suggest: { + items: Array<{ + directives: any[]; + title: string; + type: string; + }>; + }; + }; + voice_response: { + has_voice_response: boolean; + output_speech: { + text: string; + }; + should_listen: boolean; + }; + }; +}; diff --git a/library/typings/home/services/quasar.ts b/library/typings/home/services/quasar.ts new file mode 100644 index 0000000..b160164 --- /dev/null +++ b/library/typings/home/services/quasar.ts @@ -0,0 +1,185 @@ +export type QuasarInfo = { + device_id: string; + platform: string; + color: string; + multiroom_available: boolean; + multistep_scenarios_available: boolean; + device_discovery_methods: string[]; + device_setup_methods: string[]; +}; + +export type QuasarConfig = { + allow_non_self_calls?: boolean; + audio_player?: { + music_quality: string; + }; + beta?: boolean; + dndMode: { + enabled: boolean; + ends?: string; + features: { + allowIncomingCalls: boolean; + }; + platformSettings?: { + showClock?: boolean; + showIdle?: boolean; + }; + starts?: string; + }; + equalizer?: { + active_preset_id: string; + bands: Array<{ + freq: number; + gain: number; + width: number; + }>; + custom_preset_bands: [number, number, number, number, number]; + enabled: boolean; + smartEnabled: boolean; + }; + hdmiAudio?: boolean; + led?: { + brightness?: { + auto: boolean; + value: number; + }; + idle_animation?: boolean; + music_equalizer_visualization?: { + auto: boolean; + style: string; + }; + time_visualization?: { + format: string; + size?: string; + }; + }; + locale: string; + location?: { + latitude: number; + longitude: number; + }; + location_mark?: string; + name: string; + phone_number_bindings?: any; + radio_nanny_enabled?: boolean; + screenSaverConfig?: { + type: string; + }; + tv_beta?: boolean; + tv_max?: boolean; + voice_activation?: { + enabled: boolean; + }; +}; + +export type QuasarAccountConfig = { + aliceAdaptiveVolume: { + enabled: boolean; + }; + aliceProactivity: boolean; + alwaysOnMicForShortcuts: boolean; + audio_player: { + crossfadeEnabled: boolean; + }; + childContentAccess: "children" | "safe"; + contentAccess: "without" | "medium" | "children" | "safe"; + doNotUseUserLogs: boolean; + enableChildVad: boolean; + enabledCommandSpotters: { + call: { + answer: boolean; + }; + music: { + bluetooth: boolean; + feedback: boolean; + navigation: boolean; + playAndPause: boolean; + volume: boolean; + }; + smartHome: { + light: boolean; + tv: boolean; + }; + tv: { + backToHome: boolean; + navigation: boolean; + }; + }; + jingle: boolean; + saveHistoryUsage: boolean; + smartActivation: boolean; + spotter: "alisa" | "yandex"; + useBiometryChildScoring: boolean; + user_wifi_config: { + wifi_hash: string; + }; + users: any[]; +}; + +export type QuasarDeviceConfig = { + id: string; + name: string; + names: string[]; + groups: any[]; + child_device_ids: string[]; + child_multidevice_ids: string[]; + skill_id: string; + device_info: { + manufacturer: string; + model: string; + }; + favorite: boolean; + external_name: string; + external_id: string; + original_type: string; + device_type: { + original_type: string; + current_type: string; + switchable: boolean; + role_switchable: boolean; + }; + fw_upgradable: boolean; + settings: { + status_notifications: { + offline: { + available: boolean; + enabled: boolean; + delay: number; + }; + }; + }; + quasar_info: QuasarInfo; + quasar_features: { + device_discovery_methods: string[]; + device_setup_methods: string[]; + }; + quasar_config: QuasarConfig; + quasar_config_version: string; + quasar_tags: string[]; + tandem: { + candidates: any[]; + }; + voiceprint: { + status: string; + method: string; + }; + phone_linking_state: { + status: string; + device_id: string; + }; + subscription: { + enabled: boolean; + active: boolean; + completed: boolean; + }; + room: { + id: string; + name: string; + }; + household: { + id: string; + name: string; + }; + related_scenario_ids: string[]; + scenario_templates: any[]; +}; diff --git a/library/typings/index.ts b/library/typings/index.ts new file mode 100644 index 0000000..3f58c95 --- /dev/null +++ b/library/typings/index.ts @@ -0,0 +1,3 @@ +export * from "./alice"; +export * from "./home"; +export * from "./passport"; diff --git a/library/typings/passport/index.ts b/library/typings/passport/index.ts new file mode 100644 index 0000000..d7666fa --- /dev/null +++ b/library/typings/passport/index.ts @@ -0,0 +1,5 @@ +export type AuthData = { + auth_url: string; + csrf_token: string; + track_id: string; +}; diff --git a/library/utils/json.ts b/library/utils/json.ts index b1f6e5b..c74b5e5 100644 --- a/library/utils/json.ts +++ b/library/utils/json.ts @@ -1,8 +1,13 @@ -export function strictJsonParse(str: string) { - try { - const json = JSON.parse(str); - if (json && typeof json === 'object') - return json; - } catch (e) {} - return {}; -} \ No newline at end of file +export function parseJson(jsonStr: string) { + if (!jsonStr) return {} as T; + + try { + const json = JSON.parse(jsonStr); + const isNotArray = typeof json === "object" && !Array.isArray(json); + if (isNotArray) return json as T; + } catch (error) { + if (error instanceof Error) console.error("Ошибка парсинга JSON:", error.message); + } + + return {} as T; +} diff --git a/library/utils/websocket.ts b/library/utils/websocket.ts index 8cfbc0e..4102f84 100644 --- a/library/utils/websocket.ts +++ b/library/utils/websocket.ts @@ -1,159 +1,164 @@ -import EventEmitter from "events"; -import { ClientRequestArgs } from "http"; -import { ClientOptions, RawData, WebSocket } from "ws"; +import EventEmitter from "node:events"; +import type { ClientRequestArgs } from "node:http"; +import { type ClientOptions, type RawData, WebSocket } from "ws"; export type Options = { - address: () => PromiseLike; - protocols?: string | string[]; - options?: ClientOptions | ClientRequestArgs; - closeCodes?: number[]; - heartbeat?: number; - message?: { - transform?: (payload: any) => PromiseLike; - encode?: (payload: any) => PromiseLike; - decode?: (message: any) => PromiseLike; - identify?: (payload: any, message: any) => PromiseLike; - }; + address: () => PromiseLike; + protocols?: string | string[]; + options?: ClientOptions | ClientRequestArgs; + closeCodes?: number[]; + heartbeat?: number; + message?: { + transform?: (payload: any) => PromiseLike; + encode?: (payload: any) => PromiseLike; + decode?: (message: Buffer | ArrayBuffer | Buffer[]) => PromiseLike; + identify?: (payload: any, message: any) => PromiseLike; + }; }; const DefaultOptions = { - closeCodes: [1000, 1005, 1006], - heartbeat: 0, - message: { - transform: async (payload: any) => payload, - encode: async (payload: any) => payload, - decode: async (message: any) => message, - identify: async () => true - } + closeCodes: [1000, 1005, 1006], + heartbeat: 0, + message: { + transform: async (payload: any) => payload, + encode: async (payload: any) => payload, + decode: async (message: Buffer | ArrayBuffer | Buffer[]) => message, + identify: async () => true, + }, }; export class ReconnectSocket extends EventEmitter { - #websocket?: WebSocket; - #connectionPromise?: Promise; - #heartbeatTimeout?: NodeJS.Timeout; - #reconnectTimeout?: NodeJS.Timeout; - #reconnectAttempt: number = 0; - - constructor(public options: Options) { - super(); - } - - async connect(timeoutSec: number = 10) { - if (this.#connectionPromise) return this.#connectionPromise; - - this.#connectionPromise = (async () => { - try { - const address = await this.options.address(); - const { protocols, options } = this.options; - - await new Promise((resolve, reject) => { - this.#websocket = new WebSocket(address, protocols, options); - this.#websocket.on("ping", () => this.#heartbeat()); - this.#websocket.on("error", console.error); - - this.#websocket.once("open", async () => { - this.#reconnectAttempt = 0; - this.#heartbeat(); - this.emit("connect"); - resolve(); - }); - - this.#websocket.on("close", async (code, reason) => { - if (this.#reconnectAttempt === 0) { - this.emit("disconnect"); - return reject("Ошибка подключения"); - } - - const closeCodes = this.options.closeCodes ?? DefaultOptions.closeCodes; - if (this.#reconnectAttempt > 3 || closeCodes.includes(code)) { - this.#cleanup(); - this.emit("disconnect"); - } - await this.#reconnect(); - }); - - this.#websocket.on("message", async (message) => { - this.#heartbeat(); - - // Декодируем сообщение или возвращаем оригинал - const decode = this.options.message?.decode ?? DefaultOptions.message.decode; - await decode(message) - .then(decoded => this.emit("message", decoded), console.error); - }); - - setTimeout(() => reject(new Error("Таймаут подключения")), timeoutSec * 1000); - }); - } finally { - this.#connectionPromise = undefined; - } - })(); - - return this.#connectionPromise.catch(error => { - this.#cleanup(); - return Promise.reject(error); - }); - } - - async disconnect() { - return new Promise(resolve => { - if (!this.#websocket) return resolve(); - this.#websocket.once("close", resolve); - this.#websocket.close(1000); - }); - } - - async send(payload: any, timeoutSec: number = 10) { - return new Promise(async (resolve, reject) => { - await this.connect().then(undefined, reject); - - const transform = this.options.message?.transform ?? DefaultOptions.message.transform; - const encode = this.options.message?.encode ?? DefaultOptions.message.encode; - const decode = this.options.message?.decode ?? DefaultOptions.message.decode; - const identify = this.options.message?.identify ?? DefaultOptions.message.identify; - - const transformed = await transform(payload).then(undefined, reject); - const encoded = await encode(transformed).then(undefined, reject); - - async function handleMessage(this: WebSocket, message: RawData) { - const decoded = await decode(message).then(undefined, reject); - const isValid = await identify(transformed, decoded).then(undefined, reject); - if (!isValid) return; - - resolve(decoded); - this.off("message", handleMessage); - } - - this.#websocket!.on("message", handleMessage); - this.#websocket!.send(encoded); - setTimeout(() => reject(new Error("Таймаут отправки")), timeoutSec * 1000); - }); - } - - #heartbeat() { - const timeoutSec = this.options.heartbeat ?? DefaultOptions.heartbeat; - if (timeoutSec <= 0) return; - - clearTimeout(this.#heartbeatTimeout); - this.#heartbeatTimeout = - setTimeout(async () => await this.#reconnect().catch(console.error), timeoutSec * 1000); - } - - async #reconnect() { - clearTimeout(this.#reconnectTimeout); - const delay = Math.min(1000 * Math.pow(2, this.#reconnectAttempt - 1), 30000); - const jitter = Math.random() * 1000; - - this.#reconnectAttempt += 1; - this.#reconnectTimeout = - setTimeout(async () => await this.connect().catch(console.error), delay + jitter); - } - - #cleanup() { - if (this.#websocket) { - this.#websocket.removeAllListeners(); - this.#websocket = undefined; - } - clearTimeout(this.#heartbeatTimeout); - clearTimeout(this.#reconnectTimeout); - } -} \ No newline at end of file + #websocket?: WebSocket; + #connectPromise?: Promise; + #heartbeatTimeout?: NodeJS.Timeout; + #reconnectTimeout?: NodeJS.Timeout; + #reconnectAttempt = 0; + + constructor(public options: Options) { + super(); + } + + async connect(timeoutSec = 10) { + if (this.#connectPromise) return this.#connectPromise; + if (this.#websocket?.readyState === WebSocket.OPEN) return Promise.resolve(); + + this.#connectPromise = (async () => { + try { + const address = await this.options.address(); + const { protocols, options } = this.options; + + await new Promise((resolve, reject) => { + this.#websocket = new WebSocket(address, protocols, options); + this.#websocket.on("ping", () => this.#heartbeat()); + this.#websocket.on("error", console.error); + + this.#websocket.once("open", async () => { + this.#reconnectAttempt = 0; + this.#heartbeat(); + this.emit("connect"); + resolve(); + }); + + this.#websocket.on("close", async (code, reason) => { + if (this.#reconnectAttempt === 0) { + this.emit("disconnect"); + return reject("Ошибка подключения"); + } + + const closeCodes = this.options.closeCodes ?? DefaultOptions.closeCodes; + if (this.#reconnectAttempt > 3 || closeCodes.includes(code)) { + this.#cleanup(); + this.emit("disconnect"); + } + await this.#reconnect(); + }); + + this.#websocket.on("message", async (message) => { + this.#heartbeat(); + + // Декодируем сообщение или возвращаем оригинал + const decode = this.options.message?.decode ?? DefaultOptions.message.decode; + await decode(message).then((decoded) => this.emit("message", decoded), console.error); + }); + + setTimeout(() => reject(new Error("Таймаут подключения")), timeoutSec * 1000); + }); + } finally { + this.#connectPromise = undefined; + } + })(); + + return this.#connectPromise.catch((error) => { + this.#cleanup(); + return Promise.reject(error); + }); + } + + async disconnect() { + return new Promise((resolve) => { + if (!this.#websocket) return resolve(); + this.#websocket.once("close", resolve); + this.#websocket.close(1000); + }); + } + + async send(payload: any, timeoutSec = 10) { + await this.connect(); + + const transform = this.options.message?.transform ?? DefaultOptions.message.transform; + const transformed = await transform(payload); + + const encode = this.options.message?.encode ?? DefaultOptions.message.encode; + const encoded = await encode(transformed); + + return new Promise((resolve, reject) => { + const listener = async (message: RawData) => { + const decode = this.options.message?.decode ?? DefaultOptions.message.decode; + const decoded = await decode(message).then(undefined, reject); + + const identify = this.options.message?.identify ?? DefaultOptions.message.identify; + const identified = await identify(transformed, decoded).then(undefined, reject); + + if (identified) { + resolve(decoded); + this.#websocket?.off("message", listener); + } + }; + + this.#websocket?.on("message", listener); + // @ts-ignore + this.#websocket?.send(encoded); + setTimeout(() => reject(new Error("Таймаут отправки")), timeoutSec * 1000); + }); + } + + #heartbeat() { + const timeoutSec = this.options.heartbeat ?? DefaultOptions.heartbeat; + if (timeoutSec <= 0) return; + + clearTimeout(this.#heartbeatTimeout); + this.#heartbeatTimeout = setTimeout(async () => { + await this.#reconnect().catch(console.error); + }, timeoutSec * 1000); + } + + async #reconnect() { + clearTimeout(this.#reconnectTimeout); + const delay = Math.min(1000 * 2 ** (this.#reconnectAttempt - 1), 30000); + const jitter = Math.random() * 1000; + + this.#reconnectAttempt += 1; + this.#reconnectTimeout = setTimeout(async () => { + await this.connect().catch(console.error); + }, delay + jitter); + } + + #cleanup() { + if (this.#websocket) { + this.#websocket.removeAllListeners(); + this.#websocket = undefined; + } + clearTimeout(this.#heartbeatTimeout); + clearTimeout(this.#reconnectTimeout); + } +} diff --git a/package-lock.json b/package-lock.json index 0467c99..d30b674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "ws": "^8.18.0" }, "devDependencies": { + "@biomejs/biome": "1.9.4", "@tsconfig/node16": "^16.1.3", "@types/homey": "npm:homey-apps-sdk-v3-types@^0.3.10", "@types/node": "^22.12.0", @@ -19,6 +20,170 @@ "typescript": "^5.7.3" } }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@tsconfig/node16": { "version": "16.1.3", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-16.1.3.tgz", diff --git a/package.json b/package.json index 4af9c22..d764f35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,10 @@ { "name": "com.yandex", + "scripts": { + "format": "npx @biomejs/biome format --write ./library" + }, "devDependencies": { + "@biomejs/biome": "1.9.4", "@tsconfig/node16": "^16.1.3", "@types/homey": "npm:homey-apps-sdk-v3-types@^0.3.10", "@types/node": "^22.12.0",