diff --git a/__tests__/schedule.test.ts b/__tests__/schedule.test.ts new file mode 100644 index 00000000..b1575ab7 --- /dev/null +++ b/__tests__/schedule.test.ts @@ -0,0 +1,193 @@ +import { Onvif } from '../src'; +import { Schedule as ScheduleEntity, SpecialDayGroup } from '../src/interfaces/schedule'; + +const SCHEDULE_TOKEN_1 = 'ScheduleToken_1'; +const STANDARD_ICAL = + 'BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:19700101T000000\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR\nDTEND:19700101T235959\nEND:VEVENT\nEND:VCALENDAR'; +const SPECIAL_DAYS_ICAL = 'BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20200101\nEND:VEVENT\nEND:VCALENDAR'; + +let cam: Onvif; +const createdScheduleTokens: string[] = []; +const createdSpecialDayGroupTokens: string[] = []; + +beforeAll(async () => { + cam = new Onvif({ + hostname: '127.0.0.1', + username: 'admin', + password: 'admin', + port: 8000, + }); + await cam.connect(); +}); + +afterEach(async () => { + while (createdScheduleTokens.length > 0) { + const token = createdScheduleTokens.pop()!; + try { + await cam.schedule.deleteSchedule({ token }); + } catch { + // already deleted + } + } + while (createdSpecialDayGroupTokens.length > 0) { + const token = createdSpecialDayGroupTokens.pop()!; + try { + await cam.schedule.deleteSpecialDayGroup({ token }); + } catch { + // already deleted + } + } +}); + +describe('Schedule', () => { + beforeAll(() => { + if (!cam.uri.schedule) { + throw new Error('Schedule service is not available on the test device'); + } + }); + + describe('getServiceCapabilities', () => { + it('should return schedule service capabilities as an object', async () => { + const caps = await cam.schedule.getServiceCapabilities(); + expect(caps).toBeDefined(); + expect(typeof caps).toBe('object'); + expect(Array.isArray(caps)).toBe(false); + }); + + it('should return capability flags from the happytime mock server', async () => { + const caps = await cam.schedule.getServiceCapabilities(); + expect(caps.maxLimit).toBe(10); + expect(caps.maxSchedules).toBe(10); + expect(caps.maxTimePeriodsPerDay).toBe(10); + expect(caps.maxSpecialDayGroups).toBe(10); + expect(caps.extendedRecurrenceSupported).toBe(true); + expect(caps.specialDaysSupported).toBe(true); + }); + }); + + describe('getScheduleInfoList / getScheduleInfo', () => { + it('should return schedule info list from the mock server', async () => { + const list = await cam.schedule.getScheduleInfoList(); + expect(list.scheduleInfo?.length).toBeGreaterThanOrEqual(1); + expect(list.scheduleInfo?.[0]).toHaveProperty('token'); + expect(list.scheduleInfo?.[0]).toHaveProperty('name'); + expect(list.scheduleInfo?.[0].token).toBe(SCHEDULE_TOKEN_1); + }); + + it('should return schedule info for requested tokens', async () => { + const response = await cam.schedule.getScheduleInfo({ token: [SCHEDULE_TOKEN_1] }); + expect(response.scheduleInfo?.length).toBe(1); + expect(response.scheduleInfo?.[0].token).toBe(SCHEDULE_TOKEN_1); + expect(response.scheduleInfo?.[0].name).toBe('ScheduleName_1'); + expect(response.scheduleInfo?.[0].description).toBe('Schedule 1'); + }); + }); + + describe('getScheduleList / getSchedules', () => { + it('should return schedule list from the mock server', async () => { + const list = await cam.schedule.getScheduleList(); + expect(list.schedule?.length).toBeGreaterThanOrEqual(1); + expect(list.schedule?.[0]).toHaveProperty('standard'); + expect(list.schedule?.[0].standard).toContain('BEGIN:VCALENDAR'); + }); + + it('should return schedules for requested tokens', async () => { + const response = await cam.schedule.getSchedules({ token: [SCHEDULE_TOKEN_1] }); + expect(response.schedule?.length).toBe(1); + expect(response.schedule?.[0].token).toBe(SCHEDULE_TOKEN_1); + expect(response.schedule?.[0].name).toBe('ScheduleName_1'); + expect(response.schedule?.[0].standard).toContain('DTSTART:20171125T200000'); + }); + + it('should return empty list for an unknown token', async () => { + const response = await cam.schedule.getSchedules({ token: ['InvalidToken'] }); + expect(response.schedule ?? []).toHaveLength(0); + }); + }); + + describe('getScheduleState', () => { + it('should return schedule state for a valid token', async () => { + const state = await cam.schedule.getScheduleState({ token: SCHEDULE_TOKEN_1 }); + expect(typeof state.active).toBe('boolean'); + }); + + it('should reject an invalid schedule token', async () => { + await expect(cam.schedule.getScheduleState({ token: 'InvalidToken' })).rejects.toThrow('Not found'); + }); + }); + + describe('createSchedule / modifySchedule / deleteSchedule', () => { + it('should create, modify, and delete a schedule', async () => { + const schedule: ScheduleEntity = { + token: '', + name: 'TempSchedule', + description: 'temp schedule', + standard: STANDARD_ICAL, + }; + + const token = await cam.schedule.createSchedule({ schedule }); + createdScheduleTokens.push(token); + expect(token).toBeDefined(); + + await cam.schedule.modifySchedule({ + schedule: { + ...schedule, + token, + name: 'TempScheduleModified', + description: 'modified', + }, + }); + + const response = await cam.schedule.getSchedules({ token: [token] }); + expect(response.schedule?.[0].name).toBe('TempScheduleModified'); + expect(response.schedule?.[0].description).toBe('modified'); + expect(response.schedule?.[0].standard).toContain('FREQ=WEEKLY'); + + await cam.schedule.deleteSchedule({ token }); + createdScheduleTokens.pop(); + + const afterDelete = await cam.schedule.getSchedules({ token: [token] }); + expect(afterDelete.schedule ?? []).toHaveLength(0); + }); + }); + + describe('special day groups', () => { + it('should create, modify, list, and delete a special day group', async () => { + const specialDayGroup: SpecialDayGroup = { + token: '', + name: 'TempSpecialDayGroup', + description: 'temp group', + days: SPECIAL_DAYS_ICAL, + }; + + const token = await cam.schedule.createSpecialDayGroup({ specialDayGroup }); + createdSpecialDayGroupTokens.push(token); + expect(token).toBeDefined(); + + await cam.schedule.modifySpecialDayGroup({ + specialDayGroup: { + ...specialDayGroup, + token, + name: 'TempSpecialDayGroupModified', + description: 'modified group', + }, + }); + + const infoList = await cam.schedule.getSpecialDayGroupInfoList(); + expect(infoList.specialDayGroupInfo?.map((g) => g.token)).toContain(token); + + const info = await cam.schedule.getSpecialDayGroupInfo({ token: [token] }); + expect(info.specialDayGroupInfo?.[0].name).toBe('TempSpecialDayGroupModified'); + + const groups = await cam.schedule.getSpecialDayGroups({ token: [token] }); + expect(groups.specialDayGroup?.[0].description).toBe('modified group'); + expect(groups.specialDayGroup?.[0].days).toContain('DTSTART:20200101'); + + await cam.schedule.deleteSpecialDayGroup({ token }); + createdSpecialDayGroupTokens.pop(); + + const afterDelete = await cam.schedule.getSpecialDayGroups({ token: [token] }); + expect(afterDelete.specialDayGroup ?? []).toHaveLength(0); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index c6cd540c..3dd99e4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export { default as DoorControl } from './doorcontrol'; export { default as AccessControl } from './accesscontrol'; export { default as Credential } from './credential'; export { default as AccessRules } from './accessrules'; +export { default as Schedule } from './schedule'; export { default as Thermal } from './thermal'; export { default as Analytics } from './analytics'; export { default as DeviceIO } from './deviceio'; diff --git a/src/onvif.ts b/src/onvif.ts index c1233a78..7bf05a19 100644 --- a/src/onvif.ts +++ b/src/onvif.ts @@ -26,6 +26,7 @@ import type DoorControl from './doorcontrol'; import type AccessControl from './accesscontrol'; import type Credential from './credential'; import type AccessRules from './accessrules'; +import type Schedule from './schedule'; import type Thermal from './thermal'; import type Analytics from './analytics'; import type DeviceIO from './deviceio'; @@ -79,6 +80,7 @@ export interface OnvifServices { accesscontrol?: URL; credential?: URL; accessrules?: URL; + schedule?: URL; thermal?: URL; actionengine?: URL; search?: URL; @@ -356,6 +358,15 @@ export class Onvif extends EventEmitter { * ``` */ public readonly accessRules: AccessRules; + /** + * Schedule namespace for schedule v1.0 methods + * @example + * ```typescript + * const list = await onvif.schedule.getScheduleInfoList(); + * console.log(list); + * ``` + */ + public readonly schedule: Schedule; /** * Thermal namespace for thermal v1.0 methods * @example @@ -507,6 +518,7 @@ export class Onvif extends EventEmitter { this.accessControl = createLazy(this, () => import('./accesscontrol')); this.credential = createLazy(this, () => import('./credential')); this.accessRules = createLazy(this, () => import('./accessrules')); + this.schedule = createLazy(this, () => import('./schedule')); this.thermal = createLazy(this, () => import('./thermal')); this.analytics = createLazy(this, () => import('./analytics')); this.deviceIO = createLazy(this, () => import('./deviceio')); diff --git a/src/schedule.ts b/src/schedule.ts new file mode 100644 index 00000000..4a22e75a --- /dev/null +++ b/src/schedule.ts @@ -0,0 +1,314 @@ +/** + * Schedule ver10 module + * @author Andrew D.Laptev + * @see https://www.onvif.org/ver10/schedule/wsdl/schedule.wsdl + */ + +import { Onvif } from './onvif'; +import Service from './service'; +import { + Capabilities, + CreateSchedule, + CreateScheduleResponse, + CreateSpecialDayGroup, + CreateSpecialDayGroupResponse, + DeleteSchedule, + DeleteSpecialDayGroup, + GetScheduleInfo, + GetScheduleInfoList, + GetScheduleInfoListResponse, + GetScheduleInfoResponse, + GetScheduleList, + GetScheduleListResponse, + GetSchedules, + GetSchedulesResponse, + GetScheduleState, + GetSpecialDayGroupInfo, + GetSpecialDayGroupInfoList, + GetSpecialDayGroupInfoListResponse, + GetSpecialDayGroupInfoResponse, + GetSpecialDayGroupList, + GetSpecialDayGroupListResponse, + GetSpecialDayGroups, + GetSpecialDayGroupsResponse, + ModifySchedule, + ModifySpecialDayGroup, + Schedule as ScheduleEntity, + ScheduleInfo, + ScheduleState, + SetSchedule, + SetSpecialDayGroup, + SpecialDayGroup, + SpecialDayGroupInfo, + SpecialDaysSchedule, + TimePeriod, +} from './interfaces/schedule'; + +/** + * Schedule service + * @example + * ```ts + * const list = await cam.schedule.getScheduleInfoList(); + * const token = list.scheduleInfo![0].token; + * console.log((await cam.schedule.getScheduleState({ token })).active); + * ``` + */ +export default class Schedule extends Service { + constructor(onvif: Onvif) { + super(onvif, 'schedule'); + } + + private static timePeriodToBuild(period: TimePeriod) { + return { + From: period.from, + ...(period.until !== undefined && { Until: period.until }), + ...(period.extension && { Extension: period.extension }), + }; + } + + private static specialDaysScheduleToBuild(specialDays: SpecialDaysSchedule) { + return { + GroupToken: specialDays.groupToken, + ...(specialDays.timeRange && { + TimeRange: specialDays.timeRange.map(Schedule.timePeriodToBuild), + }), + ...(specialDays.extension && { Extension: specialDays.extension }), + }; + } + + private static scheduleInfoToBuild(schedule: ScheduleInfo | ScheduleEntity) { + return { + $: { token: schedule.token }, + Name: schedule.name, + ...(schedule.description && { Description: schedule.description }), + }; + } + + private static scheduleToBuild(schedule: ScheduleEntity) { + return { + ...Schedule.scheduleInfoToBuild(schedule), + Standard: schedule.standard, + ...(schedule.specialDays && { + SpecialDays: schedule.specialDays.map(Schedule.specialDaysScheduleToBuild), + }), + ...(schedule.extension && { Extension: schedule.extension }), + }; + } + + private static specialDayGroupInfoToBuild(group: SpecialDayGroupInfo | SpecialDayGroup) { + return { + $: { token: group.token }, + Name: group.name, + ...(group.description && { Description: group.description }), + }; + } + + private static specialDayGroupToBuild(group: SpecialDayGroup) { + return { + ...Schedule.specialDayGroupInfoToBuild(group), + ...(group.days !== undefined && { Days: group.days }), + ...(group.extension && { Extension: group.extension }), + }; + } + + /** + * Returns the capabilities of the schedule service. + */ + async getServiceCapabilities(): Promise { + const response = await this.request({ GetServiceCapabilities: {} }); + return response.getServiceCapabilitiesResponse?.capabilities ?? {}; + } + + /** + * Returns the current state of a schedule. + * @param options + */ + async getScheduleState({ token }: GetScheduleState): Promise { + const response = await this.request({ GetScheduleState: { Token: token } }); + return response.getScheduleStateResponse.scheduleState; + } + + /** + * Returns schedule info items for the requested tokens. + * @param options + */ + async getScheduleInfo({ token }: GetScheduleInfo): Promise { + const response = await this.request({ GetScheduleInfo: { Token: token } }, { array: ['scheduleInfo'] }); + return response.getScheduleInfoResponse || {}; + } + + /** + * Returns a list of schedule info items. + * @param options + */ + async getScheduleInfoList(options: GetScheduleInfoList = {}): Promise { + const response = await this.request( + { + GetScheduleInfoList: { + ...(options.limit !== undefined && { Limit: options.limit }), + ...(options.startReference && { StartReference: options.startReference }), + }, + }, + { array: ['scheduleInfo'] }, + ); + return response.getScheduleInfoListResponse || {}; + } + + /** + * Returns schedule items for the requested tokens. + * @param options + */ + async getSchedules({ token }: GetSchedules): Promise { + const response = await this.request( + { GetSchedules: { Token: token } }, + { array: ['schedule', 'specialDays', 'timeRange'] }, + ); + return response.getSchedulesResponse || {}; + } + + /** + * Returns a list of schedule items. + * @param options + */ + async getScheduleList(options: GetScheduleList = {}): Promise { + const response = await this.request( + { + GetScheduleList: { + ...(options.limit !== undefined && { Limit: options.limit }), + ...(options.startReference && { StartReference: options.startReference }), + }, + }, + { array: ['schedule', 'specialDays', 'timeRange'] }, + ); + return response.getScheduleListResponse || {}; + } + + /** + * Creates a new schedule. + * @param options + */ + async createSchedule({ schedule }: CreateSchedule): Promise { + const response = await this.request({ CreateSchedule: { Schedule: Schedule.scheduleToBuild(schedule) } }); + return response.createScheduleResponse.token; + } + + /** + * Creates or replaces a schedule (requires ClientSuppliedTokenSupported). + * @param options + */ + async setSchedule({ schedule }: SetSchedule): Promise { + await this.request({ SetSchedule: { Schedule: Schedule.scheduleToBuild(schedule) } }); + } + + /** + * Modifies an existing schedule. + * @param options + */ + async modifySchedule({ schedule }: ModifySchedule): Promise { + await this.request({ ModifySchedule: { Schedule: Schedule.scheduleToBuild(schedule) } }); + } + + /** + * Deletes a schedule. + * @param options + */ + async deleteSchedule({ token }: DeleteSchedule): Promise { + await this.request({ DeleteSchedule: { Token: token } }); + } + + /** + * Returns special day group info items for the requested tokens. + * @param options + */ + async getSpecialDayGroupInfo({ token }: GetSpecialDayGroupInfo): Promise { + const response = await this.request( + { GetSpecialDayGroupInfo: { Token: token } }, + { array: ['specialDayGroupInfo'] }, + ); + return response.getSpecialDayGroupInfoResponse || {}; + } + + /** + * Returns a list of special day group info items. + * @param options + */ + async getSpecialDayGroupInfoList( + options: GetSpecialDayGroupInfoList = {}, + ): Promise { + const response = await this.request( + { + GetSpecialDayGroupInfoList: { + ...(options.limit !== undefined && { Limit: options.limit }), + ...(options.startReference && { StartReference: options.startReference }), + }, + }, + { array: ['specialDayGroupInfo'] }, + ); + return response.getSpecialDayGroupInfoListResponse || {}; + } + + /** + * Returns special day group items for the requested tokens. + * @param options + */ + async getSpecialDayGroups({ token }: GetSpecialDayGroups): Promise { + const response = await this.request({ GetSpecialDayGroups: { Token: token } }, { array: ['specialDayGroup'] }); + return response.getSpecialDayGroupsResponse || {}; + } + + /** + * Returns a list of special day group items. + * @param options + */ + async getSpecialDayGroupList(options: GetSpecialDayGroupList = {}): Promise { + const response = await this.request( + { + GetSpecialDayGroupList: { + ...(options.limit !== undefined && { Limit: options.limit }), + ...(options.startReference && { StartReference: options.startReference }), + }, + }, + { array: ['specialDayGroup'] }, + ); + return response.getSpecialDayGroupListResponse || {}; + } + + /** + * Creates a new special day group. + * @param options + */ + async createSpecialDayGroup({ + specialDayGroup, + }: CreateSpecialDayGroup): Promise { + const response = await this.request({ + CreateSpecialDayGroup: { SpecialDayGroup: Schedule.specialDayGroupToBuild(specialDayGroup) }, + }); + return response.createSpecialDayGroupResponse.token; + } + + /** + * Creates or replaces a special day group (requires ClientSuppliedTokenSupported). + * @param options + */ + async setSpecialDayGroup({ specialDayGroup }: SetSpecialDayGroup): Promise { + await this.request({ SetSpecialDayGroup: { SpecialDayGroup: Schedule.specialDayGroupToBuild(specialDayGroup) } }); + } + + /** + * Modifies an existing special day group. + * @param options + */ + async modifySpecialDayGroup({ specialDayGroup }: ModifySpecialDayGroup): Promise { + await this.request({ + ModifySpecialDayGroup: { SpecialDayGroup: Schedule.specialDayGroupToBuild(specialDayGroup) }, + }); + } + + /** + * Deletes a special day group. + * @param options + */ + async deleteSpecialDayGroup({ token }: DeleteSpecialDayGroup): Promise { + await this.request({ DeleteSpecialDayGroup: { Token: token } }); + } +} diff --git a/src/service.ts b/src/service.ts index 6ea1bf76..2a521179 100644 --- a/src/service.ts +++ b/src/service.ts @@ -25,6 +25,7 @@ const XMLNS: Record = { accesscontrol: 'http://www.onvif.org/ver10/accesscontrol/wsdl', credential: 'http://www.onvif.org/ver10/credential/wsdl', accessrules: 'http://www.onvif.org/ver10/accessrules/wsdl', + schedule: 'http://www.onvif.org/ver10/schedule/wsdl', thermal: 'http://www.onvif.org/ver10/thermal/wsdl', search: 'http://www.onvif.org/ver10/search/wsdl', analyticsdevice: 'http://www.onvif.org/ver10/analyticsdevice/wsdl',