Use DNS Location Record for Location (#12409)

* Use DNS Location Record for Location

* .

* .

* .

* grammar fix

* dns class

* code changes

* composer update

* add missing composer.lock update

* reposition Location record parsing

* composer update

* default yes ; change lookup order

* merge master

* .

* move Location Record Parser to Model Level

* .

* fix location record code

* Location precedence with tests
Setting from UI disables all lookups

* update composer.lock and mix-manifest.json

* Style fixes

Co-authored-by: Tony Murray <murraytony@gmail.com>
This commit is contained in:
SourceDoctor 2021-02-22 11:17:40 +01:00 committed by GitHub
parent 93b91ebc91
commit 75a0a5e374
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 522 additions and 65 deletions

View File

@ -72,8 +72,9 @@ class OS implements Module
$deviceModel->hardware = ($hardware ?? $deviceModel->hardware) ?: null;
$deviceModel->features = ($features ?? $deviceModel->features) ?: null;
$deviceModel->serial = ($serial ?? $deviceModel->serial) ?: null;
if (! empty($location)) {
$deviceModel->setLocation(new Location(['location' => $location]));
if (! empty($location)) { // legacy support, remove when no longer needed
$deviceModel->setLocation($location);
optional($deviceModel->location)->save();
}
}
@ -102,14 +103,8 @@ class OS implements Module
private function updateLocation(\LibreNMS\OS $os)
{
$device = $os->getDevice();
if ($device->override_sysLocation) {
optional($device->location)->lookupCoordinates();
} else {
$new = $os->fetchLocation(); // fetch location data from device
$new->lookupCoordinates();
$device->setLocation($new);
}
$new_location = $device->override_sysLocation ? new Location() : $os->fetchLocation(); // fetch location data from device
$device->setLocation($new_location, true); // set location and lookup coordinates if needed
optional($device->location)->save();
}

67
LibreNMS/Util/Dns.php Normal file
View File

@ -0,0 +1,67 @@
<?php
/**
* Dns.php
*
* Get version info about LibreNMS and various components/dependencies
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @link http://librenms.org
* @copyright 2021 Thomas Berberich
* @author Thomas Berberch <sourcehhdoctor@gmail.com>
*/
namespace LibreNMS\Util;
use LibreNMS\Interfaces\Geocoder;
class Dns implements Geocoder
{
protected $resolver;
public function __construct()
{
$this->resolver = new \Net_DNS2_Resolver();
}
/**
* @param string $domain Domain which has to be parsed
* @param string $record DNS Record which should be searched
* @return array List of matching records
*/
public function getRecord($domain, $record = 'A')
{
try {
$ret = $this->resolver->query($domain, $record);
return $ret->answer;
} catch (\Net_DNS2_Exception $e) {
d_echo('::query() failed: ' . $e->getMessage());
return [];
}
}
public function getCoordinates($hostname)
{
foreach ($this->getRecord($hostname, 'LOC') as $record) {
return [
'lat' => $record->latitude,
'lng' => $record->longitude,
];
}
return [];
}
}

View File

@ -53,6 +53,7 @@ class LocationController extends Controller
]);
$location->fill($request->only(['lat', 'lng']));
$location->fixed_coordinates = true; // user has set coordinates, block automated changes
$location->save();
return response()->json(['status' => 'success']);

View File

@ -327,29 +327,37 @@ class Device extends BaseModel
/**
* Update the location to the correct location and update GPS if needed
*
* @param \App\Models\Location $location location data
* @param \App\Models\Location|string $new_location location data
* @param string $hostname
* @param bool $doLookup try to lookup the GPS coordinates
*/
public function setLocation(Location $location)
public function setLocation($new_location, $doLookup = false)
{
$location->location = $location->location ? Rewrite::location($location->location) : null;
$new_location = $new_location instanceof Location ? $new_location : new Location(['location' => $new_location]);
$new_location->location = $new_location->location ? Rewrite::location($new_location->location) : null;
$coord = array_filter($new_location->only(['lat', 'lng']));
if (! $location->location) { // disassociate if the location name is empty
$this->location_id = null;
if (! $this->override_sysLocation) {
if (! $new_location->location) { // disassociate if the location name is empty
$this->location()->dissociate();
return;
}
$coord = array_filter($location->only(['lat', 'lng']));
if (! $this->relationLoaded('location') || optional($this->location)->location !== $location->location) {
if (! $location->exists) { // don't fetch if new location persisted to the DB, just use it
$location = Location::firstOrCreate(['location' => $location->location], $coord);
return;
}
if (! $this->relationLoaded('location') || optional($this->location)->location !== $new_location->location) {
if (! $new_location->exists) { // don't fetch if new location persisted to the DB, just use it
$new_location = Location::firstOrCreate(['location' => $new_location->location], $coord);
}
$this->location()->associate($new_location);
}
$this->location()->associate($location);
}
// save new coords if needed
if ($this->location) {
$this->location->fill($coord)->save();
// set coordinates
if ($this->location && ! $this->location->fixed_coordinates) {
$this->location->fill($coord);
if ($doLookup && empty($coord)) { // only if requested and coordinates not passed explicitly
$this->location->lookupCoordinates($this->hostname);
}
}
}

View File

@ -26,30 +26,21 @@ namespace App\Models;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use LibreNMS\Util\Dns;
class Location extends Model
{
use HasFactory;
public $fillable = ['location', 'lat', 'lng'];
const CREATED_AT = null;
const UPDATED_AT = 'timestamp';
protected $casts = ['lat' => 'float', 'lng' => 'float'];
protected $casts = ['lat' => 'float', 'lng' => 'float', 'fixed_coordinates' => 'bool'];
private $location_regex = '/\[\s*(?<lat>[-+]?(?:[1-8]?\d(?:\.\d+)?|90(?:\.0+)?))\s*,\s*(?<lng>[-+]?(?:180(?:\.0+)?|(?:(?:1[0-7]\d)|(?:[1-9]?\d))(?:\.\d+)?))\s*\]/';
/**
* Set up listeners for this Model
*/
public static function boot()
{
parent::boot();
static::creating(function (Location $location) {
// parse coordinates for new locations
$location->lookupCoordinates();
});
}
// ---- Helper Functions ----
/**
@ -73,37 +64,57 @@ class Location extends Model
}
/**
* Try to parse coordinates then
* call geocoding API to resolve latitude and longitude.
* Try to parse coordinates
* then try to lookup DNS LOC records if hostname is provided
* then call geocoding API to resolve latitude and longitude.
*
* @param string $hostname
* @return bool
*/
public function lookupCoordinates()
public function lookupCoordinates($hostname = null)
{
if (! $this->hasCoordinates() && $this->location) {
$this->parseCoordinates();
if ($this->location && $this->parseCoordinates()) {
return true;
}
if (! $this->hasCoordinates() && \LibreNMS\Config::get('geoloc.latlng', true)) {
$this->fetchCoordinates();
$this->updateTimestamps();
if ($hostname && \LibreNMS\Config::get('geoloc.dns')) {
$coord = app(Dns::class)->getCoordinates($hostname);
if (! empty($coord)) {
$this->fill($coord);
return true;
}
}
if ($this->location && ! $this->hasCoordinates() && \LibreNMS\Config::get('geoloc.latlng', true)) {
return $this->fetchCoordinates();
}
return false;
}
/**
* Remove encoded GPS for nicer display
*
* @param bool $withCoords
* @return string
*/
public function display()
public function display($withCoords = false)
{
return (trim(preg_replace($this->location_regex, '', $this->location)) ?: $this->location)
. ($this->coordinatesValid() ? " [$this->lat, $this->lng]" : '');
. ($withCoords && $this->coordinatesValid() ? " [$this->lat,$this->lng]" : '');
}
protected function parseCoordinates()
{
if (preg_match($this->location_regex, $this->location, $parsed)) {
$this->fill($parsed);
return true;
}
return false;
}
protected function fetchCoordinates()
@ -112,9 +123,13 @@ class Location extends Model
/** @var \LibreNMS\Interfaces\Geocoder $api */
$api = app(\LibreNMS\Interfaces\Geocoder::class);
$this->fill($api->getCoordinates($this->location));
return true;
} catch (BindingResolutionException $e) {
// could not resolve geocoder, Laravel isn't booted. Fail silently.
}
return false;
}
// ---- Query scopes ----

View File

@ -50,6 +50,7 @@
"oriceon/toastr-5-laravel": "dev-master",
"pear/console_color2": "^0.1",
"pear/console_table": "^1.3",
"pear/net_dns2": "^1.5",
"php-amqplib/php-amqplib": "^2.0",
"phpmailer/phpmailer": "~6.0",
"predis/predis": "^1.1",

53
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0911a4d37f7dda7d8d6c8d1cfe3c1bbc",
"content-hash": "74b245e22b8cf40ae29fdc12cc2e59eb",
"packages": [
{
"name": "amenadiel/jpgraph",
@ -2874,6 +2874,57 @@
},
"time": "2018-01-25T20:47:17+00:00"
},
{
"name": "pear/net_dns2",
"version": "v1.5.2",
"source": {
"type": "git",
"url": "https://github.com/mikepultz/netdns2.git",
"reference": "d5dbae0b0c0567923d25b3ae5e2bf1e9cbcedf76"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mikepultz/netdns2/zipball/d5dbae0b0c0567923d25b3ae5e2bf1e9cbcedf76",
"reference": "d5dbae0b0c0567923d25b3ae5e2bf1e9cbcedf76",
"shasum": ""
},
"require": {
"php": ">=5.4"
},
"require-dev": {
"phpunit/phpunit": "^9"
},
"type": "library",
"autoload": {
"psr-0": {
"Net_DNS2": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Mike Pultz",
"email": "mike@mikepultz.com",
"homepage": "https://mikepultz.com/",
"role": "lead"
}
],
"description": "Native PHP DNS Resolver and Updater Library",
"homepage": "https://netdns2.com/",
"keywords": [
"PEAR",
"dns",
"network"
],
"support": {
"issues": "https://github.com/mikepultz/netdns2/issues",
"source": "https://github.com/mikepultz/netdns2"
},
"time": "2020-10-11T17:33:54+00:00"
},
{
"name": "php-amqplib/php-amqplib",
"version": "v2.12.1",

View File

@ -0,0 +1,46 @@
<?php
namespace Database\Factories;
use App\Models\Location;
use Illuminate\Database\Eloquent\Factories\Factory;
class LocationFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Location::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'location' => $this->faker->randomElement([
$this->faker->sentence($this->faker->numberBetween(1, 10)),
str_replace("\n", ' ', $this->faker->address),
]),
];
}
/**
* Indicate add lat,lng
*
* @return \Illuminate\Database\Eloquent\Factories\Factory
*/
public function withCoordinates()
{
return $this->state(function (array $attributes) {
return [
'lat' => $this->faker->latitude,
'lng' => $this->faker->longitude,
];
});
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class LocationAddFixedCoordinatesFlag extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('locations', function (Blueprint $table) {
$table->boolean('fixed_coordinates')->default(0);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('locations', function (Blueprint $table) {
$table->dropColumn('fixed_coordinates');
});
}
}

View File

@ -3,12 +3,12 @@
"/js/manifest.js": "/js/manifest.js?id=411da0f32dfa6d682e04",
"/css/app.css": "/css/app.css?id=996b9e3da0c3ab98067e",
"/js/vendor.js": "/js/vendor.js?id=54e44dd06cb8f6a3e6fe",
"/js/lang/de.js": "/js/lang/de.js?id=2c4ad02fa89b684d4f57",
"/js/lang/en.js": "/js/lang/en.js?id=3760dc4a781c3ac20b3e",
"/js/lang/fr.js": "/js/lang/fr.js?id=9552304f4fd7338af018",
"/js/lang/it.js": "/js/lang/it.js?id=514765c5399ffaa111b9",
"/js/lang/de.js": "/js/lang/de.js?id=d57e11c0b49446e43d32",
"/js/lang/en.js": "/js/lang/en.js?id=abb57dae3941488e07e9",
"/js/lang/fr.js": "/js/lang/fr.js?id=5c985dc7ace8c7f28baf",
"/js/lang/it.js": "/js/lang/it.js?id=b28a63928155eeb4e2a1",
"/js/lang/ru.js": "/js/lang/ru.js?id=f6b7c078755312a0907c",
"/js/lang/uk.js": "/js/lang/uk.js?id=c19a5dcee4724579cb41",
"/js/lang/zh-CN.js": "/js/lang/zh-CN.js?id=68da151165752f2e7983",
"/js/lang/zh-TW.js": "/js/lang/zh-TW.js?id=69f27405927f6e708f84"
"/js/lang/zh-CN.js": "/js/lang/zh-CN.js?id=12f95651fb6629cbf3f3",
"/js/lang/zh-TW.js": "/js/lang/zh-TW.js?id=87ab9d2f187593100bc3"
}

View File

@ -1,7 +1,6 @@
<?php
use App\Models\Device;
use App\Models\Location;
require_once 'includes/html/modal/device_maintenance.inc.php';
@ -16,15 +15,12 @@ if ($_POST['editing']) {
}
$override_sysLocation = (int) isset($_POST['override_sysLocation']);
$override_sysLocation_string = isset($_POST['sysLocation']) ? $_POST['sysLocation'] : null;
$override_sysLocation_string = $_POST['sysLocation'] ?? null;
if ($override_sysLocation) {
if ($override_sysLocation_string) {
$location = Location::firstOrCreate(['location' => $override_sysLocation_string]);
$device_model->location()->associate($location);
} else {
$device_model->location()->dissociate();
}
$device_model->override_sysLocation = false; // allow override (will be set to actual value later)
$device_model->setLocation($override_sysLocation_string, true);
optional($device_model->location)->save();
} elseif ($device_model->override_sysLocation) {
// no longer overridden, clear location
$device_model->location()->dissociate();

View File

@ -1632,6 +1632,13 @@
]
}
},
"geoloc.dns": {
"order": 3,
"group": "webui",
"section": "device",
"default": true,
"type": "boolean"
},
"geoloc.engine": {
"default": false,
"group": "external",

View File

@ -843,6 +843,7 @@ locations:
- { Field: lat, Type: 'double(10,6)', 'Null': true, Extra: '' }
- { Field: lng, Type: 'double(10,6)', 'Null': true, Extra: '' }
- { Field: timestamp, Type: datetime, 'Null': false, Extra: '' }
- { Field: fixed_coordinates, Type: tinyint, 'Null': false, Extra: '', Default: '0' }
Indexes:
PRIMARY: { Name: PRIMARY, Columns: [id], Unique: true, Type: BTREE }
locations_location_unique: { Name: locations_location_unique, Columns: [location], Unique: true, Type: BTREE }

View File

@ -673,6 +673,10 @@ return [
'description' => 'Mapping Engine API Key',
'help' => 'Geocoding API Key (Required to function)',
],
'dns' => [
'description' => 'Use DNS Location Record',
'help' => 'Use LOC Record from DNS Server to get geographic coordinates for Hostname',
],
'engine' => [
'description' => 'Mapping Engine',
'options' => [

233
tests/Unit/LocationTest.php Normal file
View File

@ -0,0 +1,233 @@
<?php
/*
* LocationTest.php
*
* -Description-
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @package LibreNMS
* @link http://librenms.org
* @copyright 2021 Tony Murray
* @author Tony Murray <murraytony@gmail.com>
*/
namespace LibreNMS\Tests\Unit;
use App\Models\Device;
use App\Models\Location;
use LibreNMS\Config;
use LibreNMS\Interfaces\Geocoder;
use LibreNMS\Tests\TestCase;
use LibreNMS\Util\Dns;
use Mockery\MockInterface;
class LocationTest extends TestCase
{
public function testCanSetLocation()
{
$device = Device::factory()->make(); /** @var Device $device */
$device->setLocation('Where');
$this->assertEquals($device->location->location, 'Where');
$this->assertNull($device->location->lat);
$this->assertNull($device->location->lng);
$device->setLocation(null);
$this->assertNull($device->location);
}
public function testCanNotSetLocation()
{
$device = Device::factory()->make(); /** @var Device $device */
$location = Location::factory()->make();
$device->override_sysLocation = true;
$device->setLocation($location->location);
$this->assertNull($device->location);
}
public function testCanSetEncodedLocation()
{
$device = Device::factory()->make(); /** @var Device $device */
// valid coords
$location = Location::factory()->withCoordinates()->make();
$device->setLocation("$location->location [$location->lat,$location->lng]", true);
$this->assertEquals("$location->location [$location->lat,$location->lng]", $device->location->location);
$this->assertEquals($location->location, $device->location->display());
$this->assertEquals($location->lat, $device->location->lat);
$this->assertEquals($location->lng, $device->location->lng);
// with space
$location = Location::factory()->withCoordinates()->make();
$device->setLocation("$location->location [$location->lat, $location->lng]", true);
$this->assertEquals("$location->location [$location->lat, $location->lng]", $device->location->location);
$this->assertEquals($location->location, $device->location->display());
$this->assertEquals("$location->location [$location->lat,$location->lng]", $device->location->display(true));
$this->assertEquals($location->lat, $device->location->lat);
$this->assertEquals($location->lng, $device->location->lng);
// invalid coords
$location = Location::factory()->withCoordinates()->make(['lat' => 251.5007138]);
$name = "$location->location [$location->lat,$location->lng]";
$device->setLocation($name, true);
$this->assertEquals($name, $device->location->location);
$this->assertEquals($name, $device->location->display());
$this->assertEquals($name, $device->location->display(true));
$this->assertNull($device->location->lat);
$this->assertNull($device->location->lng);
}
public function testCanHandleGivenCoordinates()
{
$device = Device::factory()->make(); /** @var Device $device */
$location = Location::factory()->withCoordinates()->make();
$device->setLocation($location);
$this->assertEquals($location->location, $device->location->location);
$this->assertEquals($location->location, $device->location->display());
$this->assertEquals("$location->location [$location->lat,$location->lng]", $device->location->display(true));
$this->assertEquals($location->lat, $device->location->lat);
$this->assertEquals($location->lng, $device->location->lng);
}
public function testCanNotSetFixedCoordinates()
{
$device = Device::factory()->make(); /** @var Device $device */
$locationOne = Location::factory()->withCoordinates()->make();
$locationTwo = Location::factory(['location' => $locationOne->location])->withCoordinates()->make();
$device->setLocation($locationOne);
$this->assertEquals($locationOne->lat, $device->location->lat);
$this->assertEquals($locationOne->lng, $device->location->lng);
$device->location->fixed_coordinates = true;
$device->setLocation($locationTwo);
$this->assertEquals($locationOne->lat, $device->location->lat);
$this->assertEquals($locationOne->lng, $device->location->lng);
$device->location->fixed_coordinates = false;
$device->setLocation($locationTwo);
$this->assertEquals($locationTwo->lat, $device->location->lat);
$this->assertEquals($locationTwo->lng, $device->location->lng);
}
public function testDnsLookup()
{
$example = 'SW1A2AA.find.me.uk';
$expected = ['lat' => 51.50354111111111, 'lng' => -0.12766972222222223];
$result = (new Dns())->getCoordinates($example);
$this->assertEquals($expected, $result);
}
public function testCanSetDnsCoordinate()
{
$device = Device::factory()->make(); /** @var Device $device */
$location = Location::factory()->withCoordinates()->make();
$this->mock(Dns::class, function (MockInterface $mock) use ($location) {
$mock->shouldReceive('getCoordinates')->once()->andReturn($location->only(['lat', 'lng']));
});
$device->setLocation($location->location, true);
$this->assertEquals($location->location, $device->location->location);
$this->assertEquals($location->lat, $device->location->lat);
$this->assertEquals($location->lng, $device->location->lng);
Config::set('geoloc.dns', false);
$device->setLocation('No DNS', true);
$this->assertEquals('No DNS', $device->location->location);
$this->assertNull($device->location->lat);
$this->assertNull($device->location->lng);
}
public function testCanSetByApi()
{
$device = Device::factory()->make(); /** @var Device $device */
$location = Location::factory()->withCoordinates()->make();
$this->mock(Geocoder::class, function (MockInterface $mock) use ($location) {
$mock->shouldReceive('getCoordinates')->once()->andReturn($location->only(['lat', 'lng']));
});
Config::set('geoloc.latlng', false);
$device->setLocation('No API', true);
$this->assertEquals('No API', $device->location->location);
$this->assertNull($device->location->lat);
$this->assertNull($device->location->lng);
Config::set('geoloc.latlng', true);
$device->setLocation('API', true);
$this->assertEquals('API', $device->location->location);
$this->assertEquals($location->lat, $device->location->lat);
$this->assertEquals($location->lng, $device->location->lng);
// preset coord = skip api
$device->setLocation('API', true);
$this->assertEquals($location->lat, $device->location->lat);
$this->assertEquals($location->lng, $device->location->lng);
}
public function testCorrectPrecedence()
{
$device = Device::factory()->make(); /** @var Device $device */
$location_encoded = Location::factory()->withCoordinates()->make();
$location_fixed = Location::factory()->withCoordinates()->make();
$location_api = Location::factory()->withCoordinates()->make();
$location_dns = Location::factory()->withCoordinates()->make();
Config::set('geoloc.dns', true);
$this->mock(Dns::class, function (MockInterface $mock) use ($location_dns) {
$mock->shouldReceive('getCoordinates')->times(3)->andReturn(
$location_dns->only(['lat', 'lng']),
[],
[]
);
});
Config::set('geoloc.latlng', true);
$this->mock(Geocoder::class, function (MockInterface $mock) use ($location_api) {
$mock->shouldReceive('getCoordinates')->once()->andReturn($location_api->only(['lat', 'lng']));
});
// fixed first
$location_fixed->location = "$location_fixed [-42, 42]"; // encoded should not be used
$device->setLocation($location_fixed, true);
$this->assertEquals($location_fixed->lat, $device->location->lat);
$this->assertEquals($location_fixed->lng, $device->location->lng);
// then encoded
$device->setLocation($location_encoded->display(true), true);
$this->assertEquals($location_encoded->lat, $device->location->lat);
$this->assertEquals($location_encoded->lng, $device->location->lng);
// then dns
$device->setLocation($location_encoded->location, true);
$this->assertEquals($location_dns->lat, $device->location->lat);
$this->assertEquals($location_dns->lng, $device->location->lng);
// then api
$device->setLocation($location_encoded->location, true);
$this->assertEquals($location_dns->lat, $device->location->lat);
$this->assertEquals($location_dns->lng, $device->location->lng);
$device->location->lat = null; // won't be used if latitude is set
$device->setLocation($location_encoded->location, true);
$this->assertEquals($location_api->lat, $device->location->lat);
$this->assertEquals($location_api->lng, $device->location->lng);
}
}