diff --git a/card.triangulate.req.notecard.api.json b/card.triangulate.req.notecard.api.json new file mode 100644 index 0000000..768493e --- /dev/null +++ b/card.triangulate.req.notecard.api.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/blues/notecard-schema/master/card.triangulate.req.notecard.api.json", + "title": "card.triangulate Request Application Programming Interface (API) Schema", + "description": "Enables or disables a behavior by which the Notecard gathers information about surrounding cell towers and/or Wi-Fi access points with each new Notehub session.", + "type": "object", + "skus": [ + "CELL", + "CELL+WIFI", + "WIFI" + ], + "version": "0.2.1", + "apiVersion": "9.1.1", + "properties": { + "cmd": { + "description": "Command for the Notecard (no response)", + "const": "card.triangulate" + }, + "req": { + "description": "Request for the Notecard (expects response)", + "const": "card.triangulate" + }, + "mode": { + "description": "The triangulation approach to use for determining the Notecard location. The following keywords can be used separately or together in a comma-delimited list, in any order.", + "type": "string", + "pattern": "^(cell(,wifi)?|wifi(,cell)?|-)$", + "sub-descriptions": [ + { + "const": "cell", + "description": "Enables cell tower scanning to determine the position of the Device." + }, + { + "const": "wifi", + "description": "Enables the use of nearby Wi-Fi access points to determine the position of the Device. To leverage this feature, the host will need to provide access point information to the Notecard via the `text` argument in subsequent requests." + }, + { + "const": "-", + "description": "Clear the currently-set triangulation mode." + } + ] + }, + "on": { + "description": "`true` to instruct the Notecard to triangulate even if the module has not moved. Only takes effect when `set` is `true`.", + "type": "boolean", + "default": false + }, + "usb": { + "description": "`true` to perform triangulation only when the Notecard is connected to USB power. Only takes effect when `set` is `true`.", + "type": "boolean", + "default": false + }, + "set": { + "description": "`true` to instruct the module to use the state of the `on` and `usb` arguments.", + "type": "boolean", + "default": false + }, + "minutes": { + "description": "Minimum delay, in minutes, between triangulation attempts. Use `0` for no time-based suppression.", + "type": "integer", + "default": 0, + "minimum": -1 + }, + "text": { + "description": "When using Wi-Fi triangulation, a newline-terminated list of Wi-Fi access points obtained by the external module. Format should follow the ESP32's [AT+CWLAP command output](https://docs.espressif.com/projects/esp-at/en/latest/AT_Command_Set/Wi-Fi_AT_Commands.html#cmd-lap).", + "type": "string" + }, + "time": { + "description": "When passed with `text`, records the time that the Wi-Fi access point scan was performed. _If not provided, Notecard time is used._", + "type": "integer" + } + }, + "oneOf": [ + { + "required": [ + "req" + ], + "properties": { + "req": { + "const": "card.triangulate" + } + } + }, + { + "required": [ + "cmd" + ], + "properties": { + "cmd": { + "const": "card.triangulate" + } + } + } + ], + "additionalProperties": false, + "samples": [ + { + "title": "Single Mode", + "description": "Enable triangulation using cell towers only.", + "json": "{\"req\":\"card.triangulate\",\"mode\":\"cell\",\"on\":true,\"set\":true}" + }, + { + "title": "Dual Mode", + "description": "Enable triangulation using both Wi-Fi and cell towers when connected to USB power.", + "json": "{\"req\":\"card.triangulate\",\"mode\":\"wifi,cell\",\"on\":true,\"usb\":true,\"set\":true}" + }, + { + "title": "Send Wi-Fi AP Data", + "description": "Send a newline-terminated list of Wi-Fi access points to the Notecard for triangulation.", + "json": "{\"req\":\"card.triangulate\",\"text\":\"+CWLAP:(4,\\\"Blues\\\",-51,\\\"74:ac:b9:12:12:f8\\\",1)\\n+CWLAP:(3,\\\"AAAA-62DD\\\",-70,\\\"6c:55:e8:91:62:e1\\\",11)\\n+CWLAP:(4,\\\"Blues\\\",-81,\\\"74:ac:b9:11:12:23\\\",1)\\n+CWLAP:(4,\\\"Blues\\\",-82,\\\"74:ac:a9:12:19:48\\\",11)\\n+CWLAP:(4,\\\"Free Parking\\\",-83,\\\"02:18:4a:11:60:31\\\",6)\\n+CWLAP:(5,\\\"GO\\\",-84,\\\"01:13:6a:13:90:30\\\",6)\\n+CWLAP:(4,\\\"AAAA-5C62-2.4\\\",-85,\\\"d8:97:ba:7b:fd:60\\\",1)\\n+CWLAP:(3,\\\"DIRECT-a5-HP MLP50\\\",-86,\\\"fa:da:0c:1b:16:a5\\\",6)\\n+CWLAP:(3,\\\"DIRECT-c6-HP M182 LaserJet\\\",-88,\\\"da:12:65:44:31:c6\\\",6)\\n\\n\"}" + }, + { + "title": "Disable Triangulation", + "description": "Disable triangulation mode.", + "json": "{\"req\":\"card.triangulate\",\"mode\":\"-\"}" + } + ], + "annotations": [ + { + "title": "note", + "description": "See [Using Cell Tower & Wi-Fi Triangulation](https://dev.blues.io/guides-and-tutorials/collecting-sensor-data/notecard-guides/using-cell-tower-wi-fi-triangulation/) for more information." + } + ] +} \ No newline at end of file diff --git a/card.triangulate.rsp.notecard.api.json b/card.triangulate.rsp.notecard.api.json new file mode 100644 index 0000000..9fe2607 --- /dev/null +++ b/card.triangulate.rsp.notecard.api.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/blues/notecard-schema/master/card.triangulate.rsp.notecard.api.json", + "title": "card.triangulate Response Application Programming Interface (API) Schema", + "description": "Response containing triangulated location information and triangulation settings.", + "type": "object", + "version": "0.1.2", + "apiVersion": "9.1.1", + "properties": { + "motion": { + "description": "UNIX Epoch time of last detected Notecard movement.", + "type": "integer", + "minimum": 0 + }, + "time": { + "description": "UNIX Epoch time of last triangulation scan.", + "type": "integer", + "minimum": 0 + }, + "mode": { + "description": "A comma-separated list indicating the active triangulation modes.", + "type": "string" + }, + "on": { + "description": "`true` if triangulation scans will be performed even if the device has not moved.", + "type": "boolean" + }, + "usb": { + "description": "`true` if triangulation scans will be performed only when the device is USB-powered.", + "type": "boolean" + }, + "length": { + "description": "The length of the `text` buffer provided in the current or a previous request.", + "type": "integer", + "minimum": 0 + } + }, + "samples": [ + { + "title": "Example Response", + "description": "Response showing triangulation configuration and status.", + "json": "{\"usb\":true,\"mode\":\"wifi,cell\",\"length\":443,\"on\":true,\"time\":1606755042,\"motion\":1606757487}" + } + ] +} \ No newline at end of file diff --git a/notecard.api.json b/notecard.api.json index 9b8a729..acfa151 100644 --- a/notecard.api.json +++ b/notecard.api.json @@ -93,6 +93,9 @@ { "$ref": "https://raw.githubusercontent.com/blues/notecard-schema/master/card.transport.req.notecard.api.json" }, + { + "$ref": "https://raw.githubusercontent.com/blues/notecard-schema/master/card.triangulate.req.notecard.api.json" + }, { "$ref": "https://raw.githubusercontent.com/blues/notecard-schema/master/card.usage.get.req.notecard.api.json" }, @@ -220,4 +223,4 @@ "$ref": "https://raw.githubusercontent.com/blues/notecard-schema/master/web.req.notecard.api.json" } ] -} +} \ No newline at end of file diff --git a/tests/test_card_triangulate_req.py b/tests/test_card_triangulate_req.py new file mode 100644 index 0000000..990dc78 --- /dev/null +++ b/tests/test_card_triangulate_req.py @@ -0,0 +1,84 @@ +import pytest +import jsonschema +import json + +SCHEMA_FILE = "card.triangulate.req.notecard.api.json" + +def test_valid_req(schema): + """Tests a minimal valid request using 'req'.""" + instance = {"req": "card.triangulate"} + jsonschema.validate(instance=instance, schema=schema) + +def test_valid_cmd(schema): + """Tests a minimal valid request using 'cmd'.""" + instance = {"cmd": "card.triangulate"} + jsonschema.validate(instance=instance, schema=schema) + +def test_invalid_empty_object(schema): + """Tests invalid empty object (needs req or cmd).""" + instance = {} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "is not valid under any of the given schemas" in str(excinfo.value) + +def test_invalid_both_req_and_cmd(schema): + """Tests invalid request having both req and cmd.""" + instance = {"req": "card.triangulate", "cmd": "card.triangulate"} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "is valid under each of" in str(excinfo.value) + +def test_invalid_additional_property_with_req(schema): + """Tests invalid request with req and an additional property.""" + instance = {"req": "card.triangulate", "extra": "field"} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "Additional properties are not allowed ('extra' was unexpected)" in str(excinfo.value) + +def test_invalid_additional_property_with_cmd(schema): + """Tests invalid request with cmd and an additional property.""" + instance = {"cmd": "card.triangulate", "extra": "field"} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "Additional properties are not allowed ('extra' was unexpected)" in str(excinfo.value) + +def test_on_field_valid(schema): + """Tests valid 'on' field values.""" + # Valid boolean values + instance = {"req": "card.triangulate", "on": True} + jsonschema.validate(instance=instance, schema=schema) + + instance = {"req": "card.triangulate", "on": False} + jsonschema.validate(instance=instance, schema=schema) + +def test_on_field_invalid_type(schema): + """Tests invalid type for 'on' field.""" + instance = {"req": "card.triangulate", "on": "true"} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "'true' is not of type 'boolean'" in str(excinfo.value) + +def test_on_field_invalid_number(schema): + """Tests invalid number type for 'on' field.""" + instance = {"req": "card.triangulate", "on": 1} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "1 is not of type 'boolean'" in str(excinfo.value) + +def test_valid_request_with_cmd_and_on(schema): + """Tests valid request using 'cmd' with 'on' parameter.""" + instance = {"cmd": "card.triangulate", "on": True} + jsonschema.validate(instance=instance, schema=schema) + +def test_validate_samples_from_schema(schema, schema_samples): + """Tests that samples in the schema definition are valid.""" + for sample in schema_samples: + sample_json_str = sample.get("json") + if not sample_json_str: + pytest.fail(f"Sample missing 'json' field: {sample.get('description', 'Unnamed sample')}") + try: + instance = json.loads(sample_json_str) + except json.JSONDecodeError as e: + pytest.fail(f"Failed to parse sample JSON: {sample_json_str}\nError: {e}") + + jsonschema.validate(instance=instance, schema=schema) diff --git a/tests/test_card_triangulate_rsp.py b/tests/test_card_triangulate_rsp.py new file mode 100644 index 0000000..b9ea1e5 --- /dev/null +++ b/tests/test_card_triangulate_rsp.py @@ -0,0 +1,141 @@ +import pytest +import jsonschema +import json + +SCHEMA_FILE = "card.triangulate.rsp.notecard.api.json" + +def test_minimal_valid_rsp(schema): + """Tests a minimal valid response (empty object).""" + instance = {} + jsonschema.validate(instance=instance, schema=schema) + +def test_motion_field(schema): + """Tests valid motion field.""" + instance = {"motion": 1606757487} + jsonschema.validate(instance=instance, schema=schema) + + # Motion cannot be negative + instance = {"motion": -1} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "-1 is less than the minimum of 0" in str(excinfo.value) + +def test_time_field(schema): + """Tests valid time field.""" + instance = {"time": 1606755042} + jsonschema.validate(instance=instance, schema=schema) + + # Time cannot be negative + instance = {"time": -1} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "-1 is less than the minimum of 0" in str(excinfo.value) + +def test_mode_field(schema): + """Tests valid mode field.""" + instance = {"mode": "wifi,cell"} + jsonschema.validate(instance=instance, schema=schema) + + instance = {"mode": "cell"} + jsonschema.validate(instance=instance, schema=schema) + + instance = {"mode": ""} + jsonschema.validate(instance=instance, schema=schema) + +def test_mode_invalid_type(schema): + """Tests invalid type for mode.""" + instance = {"mode": 123} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "123 is not of type 'string'" in str(excinfo.value) + +def test_on_field(schema): + """Tests valid on field.""" + instance = {"on": True} + jsonschema.validate(instance=instance, schema=schema) + + instance = {"on": False} + jsonschema.validate(instance=instance, schema=schema) + +def test_on_invalid_type(schema): + """Tests invalid type for on.""" + instance = {"on": 1} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "1 is not of type 'boolean'" in str(excinfo.value) + +def test_usb_field(schema): + """Tests valid usb field.""" + instance = {"usb": True} + jsonschema.validate(instance=instance, schema=schema) + + instance = {"usb": False} + jsonschema.validate(instance=instance, schema=schema) + +def test_usb_invalid_type(schema): + """Tests invalid type for usb.""" + instance = {"usb": "yes"} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "'yes' is not of type 'boolean'" in str(excinfo.value) + +def test_length_field(schema): + """Tests valid length field.""" + instance = {"length": 443} + jsonschema.validate(instance=instance, schema=schema) + + instance = {"length": 0} + jsonschema.validate(instance=instance, schema=schema) + + # Length cannot be negative + instance = {"length": -1} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "-1 is less than the minimum of 0" in str(excinfo.value) + +def test_length_invalid_type(schema): + """Tests invalid type for length.""" + instance = {"length": "443"} + with pytest.raises(jsonschema.ValidationError) as excinfo: + jsonschema.validate(instance=instance, schema=schema) + assert "'443' is not of type 'integer'" in str(excinfo.value) + +def test_complete_triangulation_response(schema): + """Tests a complete valid triangulation response.""" + instance = { + "usb": True, + "mode": "wifi,cell", + "length": 443, + "on": True, + "time": 1606755042, + "motion": 1606757487 + } + jsonschema.validate(instance=instance, schema=schema) + +def test_partial_response(schema): + """Tests valid partial response.""" + instance = { + "on": False, + "usb": True + } + jsonschema.validate(instance=instance, schema=schema) + +def test_mode_only_response(schema): + """Tests valid response with mode only.""" + instance = { + "mode": "cell" + } + jsonschema.validate(instance=instance, schema=schema) + +def test_validate_samples_from_schema(schema, schema_samples): + """Tests that samples in the schema definition are valid.""" + for sample in schema_samples: + sample_json_str = sample.get("json") + if not sample_json_str: + pytest.fail(f"Sample missing 'json' field: {sample.get('description', 'Unnamed sample')}") + try: + instance = json.loads(sample_json_str) + except json.JSONDecodeError as e: + pytest.fail(f"Failed to parse sample JSON: {sample_json_str}\nError: {e}") + + jsonschema.validate(instance=instance, schema=schema)