diff --git a/doc/2/concepts/models/index.md b/doc/2/concepts/models/index.md index 13c26cb3..276ed145 100644 --- a/doc/2/concepts/models/index.md +++ b/doc/2/concepts/models/index.md @@ -2,7 +2,7 @@ code: false type: page title: Models -description: Assets and Devices Models +description: Assets, Measure and Devices Models --- # Models @@ -58,6 +58,53 @@ The API also allows to: - list available models `device-manager/models:listDevices` - get a model `device-manager/models:getDevices` + +## Measure Model + +A measure model contains the following information: + +- `model`: model name +- `measure`: type of the measure +- `valuesMappings`: measurements mappings (See [Collection Mappings](https://docs.kuzzle.io/core/2/guides/main-concepts/data-storage/#collection-mappings)) +- `valuesDetails`: (optional) Metadata and translations of measurements. You can use it to keep consistency on translations between your apps + +It is possible to create new models on the Kuzzle IoT Platform using either: + +- the API through the action `device-manager/models:writeMeasure` +- the framework with the method `deviceManager.models.registerMeasure` + +**Example: declaration of a model via API** + +```typescript +await sdk.query({ + controller: 'device-manager/models', + action: 'writeMeasure', + body: { + type: 'light', + valuesMappings: { + light: { type: 'integer' }, + }, + valuesDetails: { + light: { + en: { + friendlyName: 'Light intensity', + unit: 'lux', + }, + fr: { + friendlyName: 'Intensité lumineuse', + unit: 'lux', + }, + }, + }, + }, + }); +``` + +The API also allows to: + +- list registered measures `device-manager/models:listMeasures` +- get a measure model `device-manager/models:getMeasure` + ## Asset Model Unlike sensors and metrics which are available for all engine groups, asset models are specific to a particular engine group. diff --git a/doc/2/controllers/models/write-measure/index.md b/doc/2/controllers/models/write-measure/index.md index 76d96591..781a417e 100644 --- a/doc/2/controllers/models/write-measure/index.md +++ b/doc/2/controllers/models/write-measure/index.md @@ -31,6 +31,10 @@ Method: POST "valuesMappings": { // Values mappings }, + // Optional + "valuesDetails":{ + // Values details and translation + } } } ``` @@ -41,6 +45,7 @@ Method: POST - `model`: Measure model name - `valuesMappings`: Mappings of the measure values in Elasticsearch format +- `valuesDetails`: (optional) Measurement translations and units --- diff --git a/lib/modules/measure/measures/BatteryMeasure.ts b/lib/modules/measure/measures/BatteryMeasure.ts index f81d020b..572eb541 100644 --- a/lib/modules/measure/measures/BatteryMeasure.ts +++ b/lib/modules/measure/measures/BatteryMeasure.ts @@ -8,4 +8,17 @@ export type BatteryMeasurement = { export const batteryMeasureDefinition: MeasureDefinition = { valuesMappings: { battery: { type: "integer" } }, + + valuesDetails: { + battery: { + en: { + friendlyName: "Battery level", + unit: "%", + }, + fr: { + friendlyName: "Niveau de batterie", + unit: "%", + }, + }, + }, }; diff --git a/lib/modules/measure/measures/HumidityMeasure.ts b/lib/modules/measure/measures/HumidityMeasure.ts index db74d5c4..b55eb918 100644 --- a/lib/modules/measure/measures/HumidityMeasure.ts +++ b/lib/modules/measure/measures/HumidityMeasure.ts @@ -8,4 +8,16 @@ export type HumidityMeasurement = { export const humidityMeasureDefinition: MeasureDefinition = { valuesMappings: { humidity: { type: "float" } }, + valuesDetails: { + humidity: { + en: { + friendlyName: "Relative humidity", + unit: "%", + }, + fr: { + friendlyName: "Humidité relative", + unit: "%", + }, + }, + }, }; diff --git a/lib/modules/measure/measures/MovementMeasure.ts b/lib/modules/measure/measures/MovementMeasure.ts index cbe72690..62f0da94 100644 --- a/lib/modules/measure/measures/MovementMeasure.ts +++ b/lib/modules/measure/measures/MovementMeasure.ts @@ -8,4 +8,14 @@ export type MovementMeasurement = { export const movementMeasureDefinition: MeasureDefinition = { valuesMappings: { movement: { type: "boolean" } }, + valuesDetails: { + movement: { + en: { + friendlyName: "Movement detection", + }, + fr: { + friendlyName: "Détection de mouvement", + }, + }, + }, }; diff --git a/lib/modules/measure/measures/PositionMeasure.ts b/lib/modules/measure/measures/PositionMeasure.ts index 1046b182..3dbbf72e 100644 --- a/lib/modules/measure/measures/PositionMeasure.ts +++ b/lib/modules/measure/measures/PositionMeasure.ts @@ -17,4 +17,26 @@ export const positionMeasureDefinition: MeasureDefinition = { accuracy: { type: "float" }, altitude: { type: "float" }, }, + valuesDetails: { + position: { + en: { + friendlyName: "Localization", + unit: "(lat,lon)", + }, + fr: { + friendlyName: "Localisation", + unit: "(lat,lon)", + }, + }, + altitude: { + en: { + friendlyName: "Altitude", + unit: "m", + }, + fr: { + friendlyName: "Altitude", + unit: "m", + }, + }, + }, }; diff --git a/lib/modules/measure/measures/TemperatureMeasure.ts b/lib/modules/measure/measures/TemperatureMeasure.ts index 799c5a1b..de92f05d 100644 --- a/lib/modules/measure/measures/TemperatureMeasure.ts +++ b/lib/modules/measure/measures/TemperatureMeasure.ts @@ -8,4 +8,16 @@ export type TemperatureMeasurement = { export const temperatureMeasureDefinition: MeasureDefinition = { valuesMappings: { temperature: { type: "float" } }, + valuesDetails: { + temperature: { + en: { + friendlyName: "Temperature", + unit: "°C", + }, + fr: { + friendlyName: "Température", + unit: "°C", + }, + }, + }, }; diff --git a/lib/modules/measure/types/MeasureDefinition.ts b/lib/modules/measure/types/MeasureDefinition.ts index 3dd9b196..3015227a 100644 --- a/lib/modules/measure/types/MeasureDefinition.ts +++ b/lib/modules/measure/types/MeasureDefinition.ts @@ -1,16 +1,58 @@ import { JSONObject } from "kuzzle-sdk"; +/* * + * Represents a measure information and localization + * + * @example + * { + * en: { + * friendlyName: "Temperature", + * unit: "°C", + * }, + * fr: { + * friendlyName: "Température", + * unit: "°C", + * }, + *} + */ + +interface MeasureLocales { + [localeString: string]: { + friendlyName: string; + unit?: string; + }; +} + +export interface MeasureValuesDetails { + [valueName: string]: MeasureLocales; +} /** * Represents a measure definition registered by the Device Manager * * @example - * { - * valuesMappings: { temperature: { type: 'float' } }, - * } + *{ + * valuesMappings: { temperature: { type: "float" } }, + * valuesDetails: { + * temperature: { + * en: { + * friendlyName: "Temperature", + * unit: "°C", + * }, + * fr: { + * friendlyName: "Température", + * unit: "°C", + * }, + * }, + * }, + *} */ + export interface MeasureDefinition { /** * Mappings for the measurement values in order to index the fields */ valuesMappings: JSONObject; + valuesDetails?: { + [valueName: string]: MeasureLocales; + }; } diff --git a/lib/modules/model/ModelService.ts b/lib/modules/model/ModelService.ts index 12aee759..130d56d8 100644 --- a/lib/modules/model/ModelService.ts +++ b/lib/modules/model/ModelService.ts @@ -36,6 +36,7 @@ import { AskModelMeasureGet, } from "./types/ModelEvents"; import { MappingsConflictsError } from "./MappingsConflictsError"; +import { MeasureValuesDetails } from "../measure"; export class ModelService extends BaseService { constructor(plugin: DeviceManagerPlugin) { @@ -316,9 +317,14 @@ export class ModelService extends BaseService { async writeMeasure( type: string, valuesMappings: JSONObject, + valuesDetails?: MeasureValuesDetails, ): Promise> { const modelContent: MeasureModelContent = { - measure: { type, valuesMappings }, + measure: { + type, + valuesDetails, + valuesMappings, + }, type: "measure", }; diff --git a/lib/modules/model/ModelsController.ts b/lib/modules/model/ModelsController.ts index 5b495a43..71553d0b 100644 --- a/lib/modules/model/ModelsController.ts +++ b/lib/modules/model/ModelsController.ts @@ -154,10 +154,11 @@ export class ModelsController { ): Promise { const type = request.getBodyString("type"); const valuesMappings = request.getBodyObject("valuesMappings"); - + const valuesDetails = request.getBodyObject("valuesDetails", {}); const measureModel = await this.modelService.writeMeasure( type, valuesMappings, + valuesDetails, ); return measureModel; diff --git a/lib/modules/model/ModelsRegister.ts b/lib/modules/model/ModelsRegister.ts index d6f12e28..8ad00939 100644 --- a/lib/modules/model/ModelsRegister.ts +++ b/lib/modules/model/ModelsRegister.ts @@ -132,7 +132,11 @@ export class ModelsRegister { registerMeasure(type: string, measureDefinition: MeasureDefinition) { this.measureModels.push({ - measure: { type, valuesMappings: measureDefinition.valuesMappings }, + measure: { + type, + valuesDetails: measureDefinition.valuesDetails, + valuesMappings: measureDefinition.valuesMappings, + }, type: "measure", }); } diff --git a/lib/modules/model/collections/modelsMappings.ts b/lib/modules/model/collections/modelsMappings.ts index 955b628f..61056c08 100644 --- a/lib/modules/model/collections/modelsMappings.ts +++ b/lib/modules/model/collections/modelsMappings.ts @@ -21,6 +21,10 @@ export const modelsMappings: CollectionMappings = { dynamic: "false", properties: {}, }, + valuesDetails: { + dynamic: "false", + properties: {}, + }, }, }, diff --git a/lib/modules/model/types/ModelApi.ts b/lib/modules/model/types/ModelApi.ts index b16fc713..e3af7d92 100644 --- a/lib/modules/model/types/ModelApi.ts +++ b/lib/modules/model/types/ModelApi.ts @@ -8,6 +8,7 @@ import { MetadataGroups, MetadataMappings, } from "./ModelContent"; +import { MeasureValuesDetails } from "../../measure/types/MeasureDefinition"; interface ModelsControllerRequest { controller: "device-manager/models"; @@ -67,6 +68,7 @@ export interface ApiModelWriteMeasureRequest extends ModelsControllerRequest { body: { type: string; valuesMappings: JSONObject; + valuesDetails?: MeasureValuesDetails; }; } export type ApiModelWriteMeasureResult = KDocument; diff --git a/tests/fixtures/rights.ts b/tests/fixtures/rights.ts index 0aa0588c..c8883246 100644 --- a/tests/fixtures/rights.ts +++ b/tests/fixtures/rights.ts @@ -22,6 +22,14 @@ export default { }, }, }, + "default-user": { + content: { + profileIds: ["default-user"], + }, + credentials: { + local: { username: "default-user", password: "password" }, + }, + }, }, profiles: { "ayse-admin": { @@ -70,7 +78,21 @@ export default { }, ], }, + "default-user": { + rateLimit: 0, + policies: [ + { + roleId: "default-user", + }, + ], + optimizedPolicies: [ + { + roleId: "default-user", + }, + ], + }, }, + roles: { tests: { controllers: { @@ -92,5 +114,14 @@ export default { }, }, }, + "default-user": { + controllers: { + "device-manager/assets": { + actions: { + "*": true, + }, + }, + }, + }, }, }; diff --git a/tests/hooks/engines.ts b/tests/hooks/engines.ts index f1fe3bea..99112b73 100644 --- a/tests/hooks/engines.ts +++ b/tests/hooks/engines.ts @@ -1,4 +1,5 @@ import { BaseRequest, JSONObject, Kuzzle } from "kuzzle-sdk"; +import { loadSecurityDefault } from "./security"; async function createEngineIfNotExists( sdk: Kuzzle, @@ -24,6 +25,7 @@ async function createEngineIfNotExists( } export async function beforeAllCreateEngines(sdk: Kuzzle) { + await loadSecurityDefault(sdk); await Promise.all([ createEngineIfNotExists(sdk, "engine-ayse"), createEngineIfNotExists(sdk, "engine-kuzzle"), diff --git a/tests/scenario/migrated/model-controller.test.ts b/tests/scenario/migrated/model-controller.test.ts index 7810f398..65dc7f1f 100644 --- a/tests/scenario/migrated/model-controller.test.ts +++ b/tests/scenario/migrated/model-controller.test.ts @@ -504,55 +504,63 @@ describe("features/Model/Controller", () => { model: "AdvancedPlane", metadataMappings: { company: { type: "keyword" }, - year: { type: "integer" } + year: { type: "integer" }, }, - measures: [ - { name: "temperatureExt", type: "temperature" }, - ], + measures: [{ name: "temperatureExt", type: "temperature" }], metadataDetails: { company: { group: "companyInfo", locales: { en: { friendlyName: "Manufacturer", - description: "The company that manufactured the plane" + description: "The company that manufactured the plane", }, fr: { friendlyName: "Fabricant", - description: "L'entreprise qui a fabriqué l'avion" - } - } - } + description: "L'entreprise qui a fabriqué l'avion", + }, + }, + }, }, metadataGroups: { companyInfo: { locales: { - en: { groupFriendlyName: "Company Information", description: "All company related informations" }, - fr: { groupFriendlyName: "Informations sur l'entreprise", description: "Toutes les informations relatives a l'entreprise" } - } - } - } + en: { + groupFriendlyName: "Company Information", + description: "All company related informations", + }, + fr: { + groupFriendlyName: "Informations sur l'entreprise", + description: "Toutes les informations relatives a l'entreprise", + }, + }, + }, + }, }; // Write the asset model with metadata details and groups await sdk.query({ controller: "device-manager/models", action: "writeAsset", - body: assetModelWithDetailsAndGroups + body: assetModelWithDetailsAndGroups, }); // Retrieve and assert the asset model - const response = await sdk.document.get("device-manager", "models", "model-asset-AdvancedPlane"); - expect(response._source.asset).toHaveProperty('metadataDetails'); - expect(response._source.asset).toHaveProperty('metadataGroups'); - delete assetModelWithDetailsAndGroups.engineGroup + const response = await sdk.document.get( + "device-manager", + "models", + "model-asset-AdvancedPlane" + ); + expect(response._source.asset).toHaveProperty("metadataDetails"); + expect(response._source.asset).toHaveProperty("metadataGroups"); + delete assetModelWithDetailsAndGroups.engineGroup; expect(response._source).toMatchObject({ type: "asset", engineGroup: "commons", - asset: assetModelWithDetailsAndGroups + asset: assetModelWithDetailsAndGroups, }); }); - + it("Register models from the framework", async () => { let response; let promise; diff --git a/tests/scenario/modules/assets/asset-migrate-tenant.test.ts b/tests/scenario/modules/assets/asset-migrate-tenant.test.ts index 2916a8aa..df068711 100644 --- a/tests/scenario/modules/assets/asset-migrate-tenant.test.ts +++ b/tests/scenario/modules/assets/asset-migrate-tenant.test.ts @@ -23,28 +23,14 @@ describe("AssetsController:migrateTenant", () => { sdk.disconnect(); }); - it("should fail if the user is not an admin", async () => { - await expect( - sdk.query({ - controller: "device-manager/assets", - action: "migrateTenant", - engineId: "engine-ayse", - body: { - assetsList: ["Container-linked1", "Container-linked2"], - newEngineId: "engine-kuzzle", - }, - }), - ).rejects.toThrow("User -1 is not authorized to migrate assets"); - }); - it("should fail if both engine does not belong to same group", async () => { - // We connect only here to avoid failing the first test - // If we do it in the beforeAll hook, the first test will fail - // And if we run it each time we might encounter "Too many login attempts per second" await sdk.auth.login("local", { username: "test-admin", password: "password", }); + // We connect only here to avoid failing the first test + // If we do it in the beforeAll hook, the first test will fail + // And if we run it each time we might encounter "Too many login attempts per second" await expect( sdk.query({ @@ -158,4 +144,21 @@ describe("AssetsController:migrateTenant", () => { expect(response.status).toBe(200); expect(assets.result.hits).toHaveLength(2); }); + it("should fail if the user is not an admin", async () => { + await sdk.auth.login("local", { + username: "default-user", + password: "password", + }); + await expect( + sdk.query({ + controller: "device-manager/assets", + action: "migrateTenant", + engineId: "engine-ayse", + body: { + assetsList: ["Container-linked1", "Container-linked2"], + newEngineId: "engine-kuzzle", + }, + }), + ).rejects.toThrow("User default-user is not authorized to migrate assets"); + }); }); diff --git a/tests/scenario/modules/devices/action-export-measures.test.ts b/tests/scenario/modules/devices/action-export-measures.test.ts index 9ce66c62..1b31de5c 100644 --- a/tests/scenario/modules/devices/action-export-measures.test.ts +++ b/tests/scenario/modules/devices/action-export-measures.test.ts @@ -176,11 +176,6 @@ describe("DevicesController:exportMeasures", () => { }); it("should generate a authenticated link", async () => { - await sdk.auth.login("local", { - username: "test-admin", - password: "password", - }); - await sendPayloads(sdk, "dummy-temp", [ { deviceEUI: "linked1", temperature: 37 }, ]); diff --git a/tests/scenario/modules/models/model-controller.test.ts b/tests/scenario/modules/models/model-controller.test.ts index ee288c2a..5aca6379 100644 --- a/tests/scenario/modules/models/model-controller.test.ts +++ b/tests/scenario/modules/models/model-controller.test.ts @@ -284,4 +284,58 @@ describe("features/Model/Controller", () => { ), ).rejects.toThrow(); }); + + it("can accept valuesDetails when writing a measure", async () => { + await sdk.query({ + controller: "device-manager/models", + action: "writeMeasure", + body: { + type: "light", + valuesMappings: { + light: { type: "float" }, + }, + valuesDetails: { + light: { + en: { + friendlyName: "Light intensity", + unit: "lux", + }, + fr: { + friendlyName: "Intensité lumineuse", + unit: "lux", + }, + }, + }, + }, + }); + await expect( + sdk.document.get( + "device-manager", + "models", + "model-measure-light", + ), + ).resolves.toMatchObject>>({ + _source: { + type: "measure", + measure: { + type: "light", + valuesMappings: { + light: { type: "float" }, + }, + valuesDetails: { + light: { + en: { + friendlyName: "Light intensity", + unit: "lux", + }, + fr: { + friendlyName: "Intensité lumineuse", + unit: "lux", + }, + }, + }, + }, + }, + }); + }); }); diff --git a/tests/scenario/usecase/dynamicaly-register-device-model.test.ts b/tests/scenario/usecase/dynamicaly-register-device-model.test.ts index 4ad4fb1a..e9b8122b 100644 --- a/tests/scenario/usecase/dynamicaly-register-device-model.test.ts +++ b/tests/scenario/usecase/dynamicaly-register-device-model.test.ts @@ -8,12 +8,10 @@ import { } from "../../../index"; import { setupHooks } from "../../helpers"; - +const sdk = setupHooks(); jest.setTimeout(10000); describe("DeviceScenario: dynamicaly register device model and receive a measure", () => { - const sdk = setupHooks(); - it("register a new device model, create a device from this model and receive a formated measure", async () => { await sdk.query({ controller: "device-manager/models",