Skip to content

Commit

Permalink
Новые API и улучшение кода
Browse files Browse the repository at this point in the history
  • Loading branch information
kraineff committed Jan 30, 2025
1 parent aa97a32 commit 300803f
Show file tree
Hide file tree
Showing 14 changed files with 378 additions and 260 deletions.
171 changes: 94 additions & 77 deletions drivers/Device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class Device extends Homey.Device {

private lastTrackId: string = "";
private lastTrackAlbumId: string = "";
private lastTrackImage?: string;
private lastTrackImage: string = "https://";
private lastTrackLyrics: Array<string> = [];
private lastTrackLyricsTimeout?: NodeJS.Timeout;

Expand All @@ -24,13 +24,18 @@ export default class Device extends Homey.Device {
this.id = data.id as string;
this.yandex = (this.homey.app as any).yandex;

// Значение по умолчанию при создании колонки
this.getCapabilityValue("speaker_playing") ??
await this.setCapabilityValue("speaker_playing", false);

// Создание и установка обложки
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);

Expand Down Expand Up @@ -163,18 +168,26 @@ export default class Device extends Homey.Device {
await speaker.controlClick();
});
}

private handleState = async (state: Partial<YandexSpeakerState>) => {
const capabilities: Record<string, any> = {
"speaker_playing": state.playing,
"speaker_shuffle": state.playerState?.entityInfo?.shuffled,
"speaker_repeat": state.playerState?.entityInfo?.repeatMode,
"speaker_duration": state.playerState?.duration,
"speaker_position": state.playerState?.progress,
"volume_set": state.volume,
"speaker_playing": state.playing || false,
"speaker_shuffle": state.playerState?.entityInfo?.shuffled || false,
"speaker_repeat": state.playerState?.entityInfo?.repeatMode || "None",
"speaker_duration": state.playerState?.duration || 0,
"speaker_position": state.playerState?.progress || 0,
"volume_set": state.volume || this.getCapabilityValue("volume_set"),
"media_rewind": Math.round(state.playerState?.progress || 0)
};

const repeatMode = { None: "none", One: "track", All: "playlist" } as any;
capabilities.speaker_repeat = capabilities.speaker_repeat && repeatMode[capabilities.speaker_repeat];

const rewindOptions = this.getCapabilityOptions("media_rewind");
if (capabilities.speaker_duration && capabilities.speaker_duration !== rewindOptions.max) {
await this.setCapabilityOptions("media_rewind", { min: 0, max: capabilities.speaker_duration, step: 1 });
}

// Для нового трека
const trackId = state.playerState?.id || "";
if (trackId !== this.lastTrackId) {
Expand All @@ -183,56 +196,73 @@ export default class Device extends Homey.Device {
this.lastTrackAlbumId = track?.albums?.[0]?.id || "";
this.lastTrackLyrics = [];

// Обновление обложки
const trackImage = track?.coverUri || track?.ogImage || state.playerState?.extra?.coverURI;
if (trackImage) {
const imageQuality = this.getSetting("image_quality") || 500;
this.lastTrackImage = "https://" + trackImage.replace("%%", `${imageQuality}x${imageQuality}`);
this.image.setUrl(this.lastTrackImage);
await this.image.update();
}
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.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;
}

// Обновление текста песни
let lyricsValues = [{ id: "none", title: "Нет текста песни" }];
if (track?.lyricsInfo?.hasAvailableSyncLyrics) {
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];
const timeColons = time.split(":").map(c => Number(c));
const timeSeconds = String((timeColons[0] * 60) + timeColons[1]);
const title = line.replace(`[${time}] `, "") || "-";

this.lastTrackLyrics.push(timeSeconds);
return { id: timeSeconds, title };
});
values.length && (lyricsValues = values);
}
await this.setCapabilityOptions("media_lyrics", { values: lyricsValues });
await this.handleLyricsSync(state);
await this.handleAliceState(state);

// Обновление лайка и дизлайка
const likedTracks = await this.yandex.api.music.getLikes(this.userId).catch(() => []);
capabilities["media_like"] = likedTracks.find(track => track.id === trackId) ? true : false;

const dislikedTracks = await this.yandex.api.music.getDislikes(this.userId).catch(() => []);
capabilities["media_dislike"] = dislikedTracks.find(track => track.id === trackId) ? true : false;
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];
return Promise.resolve();
}

// Обновление названия, исполнителей, альбома
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;
}
const currentValue = this.getCapabilityValue(capability);
if (currentValue !== value)
await this.setCapabilityValue(capability, value).catch(this.error);
})
);
};

const repeatMode = { None: "none", One: "track", All: "playlist" } as any;
capabilities.speaker_repeat = capabilities.speaker_repeat && repeatMode[capabilities.speaker_repeat];
private async updateTrackCover(state: Partial<YandexSpeakerState>, 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();
}

const rewindOptions = this.getCapabilityOptions("media_rewind");
if (capabilities.speaker_duration && capabilities.speaker_duration !== rewindOptions.max) {
await this.setCapabilityOptions("media_rewind", { min: 0, max: capabilities.speaker_duration, step: 1 });
private async updateTrackLyrics(state: Partial<YandexSpeakerState>, 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 lyricsLines = lyrics.split("\n");
const values = lyricsLines.map(line => {
const time = line.split("[")[1].split("] ")[0];
const timeColons = time.split(":").map(c => Number(c));
const timeSeconds = String((timeColons[0] * 60) + timeColons[1]);
const title = line.replace(`[${time}] `, "") || "-";

this.lastTrackLyrics.push(timeSeconds);
return { id: timeSeconds, title };
});
values.length && (lyricsValues = values);
}

if (capabilities.speaker_position && this.lastTrackLyrics.length) {
const position = capabilities.speaker_position;
await this.setCapabilityOptions("media_lyrics", { values: lyricsValues });
}

private async handleLyricsSync(state: Partial<YandexSpeakerState>) {
const position = state.playerState?.progress;

if (position !== undefined && this.lastTrackLyrics.length) {
const closest = this.lastTrackLyrics.find(s => Number(s) >= position) || this.lastTrackLyrics[0];
const between = Number(closest) - position;

Expand All @@ -244,38 +274,25 @@ export default class Device extends Homey.Device {
}, between * 1000);
}
}
}

private async handleAliceState(state: Partial<YandexSpeakerState>) {
const aliceState = state.aliceState || "";

if (!this.aliceActive && (aliceState === "LISTENING" || aliceState === "SPEAKING")) {
const handle = async (state: Partial<YandexSpeakerState>) => {
this.aliceActive = true;
this.image.setUrl("https://i.imgur.com/vTa3rif.png");
await this.image.update();

const listener = async (state: Partial<YandexSpeakerState>) => {
if (state.aliceState !== "IDLE") return;

this.aliceActive = false;
this.speaker!.off("state", handle);
this.lastTrackImage && this.image.setUrl(this.lastTrackImage);
this.image.setUrl(this.lastTrackImage);
await this.image.update();
};

this.aliceActive = true;
this.speaker!.on("state", handle);
this.image.setUrl("https://i.imgur.com/vTa3rif.png");
await this.image.update();
this.speaker?.off("state", listener);
};
this.speaker?.on("state", listener);
}

await Promise.all(
Object.entries(capabilities).map(async ([capability, value]) => {
const capabilityValue = this.getCapabilityValue(capability);
const stateValue = value ?? null;

if (Object.keys(this.waitings).includes(capability)) {
if (Date.now() - this.waitings[capability] >= 3000)
delete this.waitings[capability];
return Promise.resolve();
}

if (capabilityValue !== stateValue)
await this.setCapabilityValue(capability, stateValue).catch(this.error);
})
);
};
}
}
13 changes: 8 additions & 5 deletions library/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { AxiosInstance } from "axios";
import { YandexStorage } from "../storage/index.js";
import { createInstance } from "./utils.js";
import type { AxiosInstance } from "axios";
import { YandexStorage } from "../storage.js";
import { YandexAliceAPI } from "./services/alice.js";
import { YandexMusicAPI } from "./services/music.js";
import { YandexPassportAPI } from "./services/passport.js";
import { YandexQuasarAPI } from "./services/quasar.js";
import { YandexMusicAPI } from "./services/music.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 music: YandexMusicAPI;

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);
Expand Down
113 changes: 113 additions & 0 deletions library/api/services/alice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { AxiosInstance } from "axios";
import type { YandexStorage } from "../../storage.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;

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"}',
}
}));
}

async getAlarms(deviceIds: Array<string>) {
return await this.client
.post("https://rpc.alice.yandex.ru/gproxy/get_alarms", { device_ids: deviceIds })
.then(res => res.data.alarms as Array<YandexAlarm>);
}

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 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 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 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<YandexReminder>);
}

async createReminder(reminder: YandexReminder) {
await this.client
.post("https://rpc.alice.yandex.ru/gproxy/create_reminder", reminder);
}

async updateReminder(reminder: YandexReminder) {
await this.client
.post("https://rpc.alice.yandex.ru/gproxy/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 cancelReminder(reminderId: string) {
await this.cancelReminders([reminderId]);
}
}
Loading

0 comments on commit 300803f

Please sign in to comment.