Skip to content

Commit

Permalink
Decode JSON for CleanedArea and InteractiveMap data
Browse files Browse the repository at this point in the history
  • Loading branch information
thoukydides authored Nov 18, 2023
1 parent f590abc commit 03f6eb8
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 20 deletions.
17 changes: 10 additions & 7 deletions src/aegapi-appliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import { createCheckers } from 'ts-interface-checker';

import { AEGAuthoriseUserAgent } from './aegapi-ua-auth';
import { ApplianceInfo, ApplianceNamePatch, AppliancePut, Capabilities,
CleanedAreas, CleaningCommand, DeleteTask, InteractiveMaps, Lifetime,
import { Appliance, ApplianceInfo, ApplianceNamePatch, AppliancePut,
Capabilities, CleanedAreas, CleanedAreaSessionMap, CleaningCommand,
DeleteTask, InteractiveMaps, InteractiveMapData, Lifetime,
PowerMode, PutCommand, PutCommandZone, PutTask, PostNewTask, NewTask,
Task, Tasks, PatchApplianceName, PutAppliance, Appliance } from './aegapi-types';
Task, Tasks, PatchApplianceName, PutAppliance } from './aegapi-types';
import aegapiTI from './ti/aegapi-types-ti';

// Checkers for API responses
Expand Down Expand Up @@ -108,17 +109,19 @@ export class AEGApplianceAPI {
return this.ua.getJSON(checkers.CleanedAreas, path);
}

getApplianceSessionMap(sessionId: number) {
getApplianceSessionMap(sessionId: number): Promise<CleanedAreaSessionMap> {
const path = `/purei/api/v2/appliances/${this.applianceId}/cleaning-sessions/${sessionId}/maps`;
const query = { mapFormat: 'rawgzip' };
return this.ua.getBinary(path, { query });
const headers = { Accept: '*/*' };
return this.ua.getJSON(checkers.CleanedAreaSessionMap, path, { query, headers });
}

getApplianceInteractiveMap(persistentMapId: string, sequenceNumber: number) {
async getApplianceInteractiveMap(persistentMapId: string, sequenceNumber: number): Promise<InteractiveMapData> {
const path = `/purei/api/v2/appliances/${this.applianceId}/interactive-maps/`
+ `${persistentMapId}/sequences/${sequenceNumber}/maps`;
const query = { mapFormat: 'rawgzip' };
return this.ua.getBinary(path, { query });
const headers = { Accept: '*/*' };
return this.ua.getJSON(checkers.InteractiveMapData, path, { query, headers });
}

getApplianceCapabilities(): Promise<Capabilities> {
Expand Down
60 changes: 60 additions & 0 deletions src/aegapi-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,35 @@ export interface InteractiveMap {
}
export type InteractiveMaps = InteractiveMap[];

// GET /purei/api/v2/appliances/${applianceId}/interactive-maps/${persistentMapId}/sequences/${sequenceNumber}/maps
export interface MapPoint {
t: number; // e.g. 1000
xy: [number, number]; // e.g. [-0.24129055, 0.31136945]
}
export interface MapPointAngle {
t?: number; // e.g. 1000
xya: [number, number, number];
// e.g. [0.05495656, -0.03892141, -0.029699445]
}
export interface MapTransform {
t: number; // e.g. 1000
xya: [number, number, number];
// e.g. [0.05495656, -0.03892141, -0.029699445]
}
export interface InteractiveMapData {
uuid: string; // e.g. '8f413222-546d-4c89-acbc-a47323236a4d'
mapState: number; // e.g. 0
sequenceNo: number; // e.g. 21
sessionId: number; // e.g. 245
timestamp: {
isReliable: boolean;
time: string; // e.g. '2023-11-08T10:54:33+0000'
};
chargerPose: MapPointAngle;
crumbs: MapPoint[];
transforms: MapTransform[];
}

// GET /purei/api/v2/appliances/${applianceId}/lifetime
export interface Lifetime {
cleaningDuration: number; // e.g. 9750000000 (0.1µs ticks?)
Expand Down Expand Up @@ -456,6 +485,37 @@ export interface CleanedArea {
}
export type CleanedAreas = CleanedArea[];

// GET /purei/api/v2/appliances/${applianceId}/cleaning-sessions/${sessionId}/maps
export interface CleanedAreaSessionZone {
uuid: string; // e.g. '172b79d6-96ce-469f-89c0-5bb9ed696810'
type: number; // e.g. 0
vertices: MapPoint[];
}
export interface CleanedAreaSessionMapMatch {
uuid: string; // e.g. 8f413222-546d-4c89-acbc-a47323236a4d
sequenceNo: number; // e.g. 21,
zones: CleanedAreaSessionZone[];

}
export interface CleanedAreaSessionZoneStatus {
uuid: string; // e.g. '172b79d6-96ce-469f-89c0-5bb9ed696810'
powerMode: PowerMode;
status: number; // e.g. 4
}
export interface CleanedAreaSessionMap {
sessionId: number; // e.g. 255
timestamp: string; // e.g. '2023-11-18T10:14:09'
cleaningComplete: number; // e.g. 1
crumbs: MapPoint[];
crumbCollectionDelta: boolean;
chargerPoses: MapPointAngle[];
robotPose: MapPointAngle;
robotPoseReliable: boolean;
transforms: MapTransform[];
mapMatch: CleanedAreaSessionMapMatch;
zoneStatus: CleanedAreaSessionZoneStatus[];
}

// GET /domain/api/v2/domains
export interface DomainAppliance extends ApplianceDataWithPNC {

Expand Down
31 changes: 18 additions & 13 deletions src/aegapi-ua.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Logger, LogLevel } from 'homebridge';

import { STATUS_CODES } from 'http';
import { Client, Dispatcher } from 'undici';
import { buffer } from 'stream/consumers';
import { gunzipSync } from 'zlib';
import { Checker, IErrorDetail } from 'ts-interface-checker';

import { AEG_API_KEY, AEG_API_URL,
Expand Down Expand Up @@ -115,20 +117,30 @@ export class AEGUserAgent {
async requestJSON<Type>(checker: Checker, ...params: RequestParams): Promise<Type> {
const { request, response } = await this.request(...params, { Accept: 'application/json' });

// Check that a JSON response was returned
// https://github.com/nodejs/undici/blob/main/docs/api/Dispatcher.md#dispatcherrequestoptions-callback
// Check that the response was not empty
if (response.statusCode === 204)
throw new AEGAPIError(request, response, 'Unexpected empty response (status code 204 No Content)');

// Retrieve the response as JSON text
let text;
const contentType = response.headers['content-type'];
if (typeof contentType !== 'string'
|| !contentType.startsWith('application/json')) {
if (typeof contentType === 'string'
&& contentType.startsWith('application/json')) {
text = await response.body.text();
} else if (contentType === 'application/octet-stream') {
try {
const gzipped = await buffer(response.body);
text = gunzipSync(gzipped).toString();
} catch (cause) {
throw new AEGAPIError(request, response, `Failed to gunzip binary response (${cause})`, { cause });
}
} else {
throw new AEGAPIError(request, response, `Unexpected response content-type (${contentType})`);
}

// Retrieve and parse the response as JSON
// Parse the response as JSON
let json;
try {
const text = await response.body.text();
this.logBody('Response', text);
json = JSON.parse(text);
} catch (cause) {
Expand All @@ -153,13 +165,6 @@ export class AEGUserAgent {
return json;
}

// Requests that expect a binary response
getBinary(path: string, options?: UAOptions): Promise<Binary> { return this.requestBinary('GET', path, options, undefined); }
async requestBinary(...params: RequestParams): Promise<Binary> {
const { response } = await this.request(...params, { Accept: '*/*' });
return response.body;
}

// Construct and issue a request, retrying if appropriate
async request(method: Method, path: string, options?: UAOptions,
body?: object, headers?: Headers): Promise<RequestResponse> {
Expand Down

0 comments on commit 03f6eb8

Please sign in to comment.