diff --git a/laravel-app/app/Http/Controllers/DeviceController.php b/laravel-app/app/Http/Controllers/DeviceController.php index 524eeb9..a537299 100644 --- a/laravel-app/app/Http/Controllers/DeviceController.php +++ b/laravel-app/app/Http/Controllers/DeviceController.php @@ -59,11 +59,11 @@ class DeviceController extends Controller ]); } - public function create() + public function create(DeviceShadow $shadow) { $this->authorize('create', Device::class); - return Inertia::render('Devices/Create', $this->formOptions()); + return Inertia::render('Devices/Create', $this->formOptions($shadow)); } public function store(DeviceRequest $request) @@ -75,13 +75,13 @@ class DeviceController extends Controller return redirect()->route('devices.index')->with('status', 'Устройство добавлено.'); } - public function edit(Device $device) + public function edit(Device $device, DeviceShadow $shadow) { $this->authorize('update', $device); return Inertia::render('Devices/Edit', [ 'device' => $device->only(['id', 'name', 'zone_id', 'device_type_id', 'external_id', 'protocol']), - ...$this->formOptions(), + ...$this->formOptions($shadow), ]); } @@ -150,11 +150,21 @@ class DeviceController extends Controller /** * @return array */ - private function formOptions(): array + private function formOptions(DeviceShadow $shadow): array { + $registeredIds = Device::pluck('external_id')->all(); + $unregisteredExternalIds = collect($shadow->knownExternalIds()) + ->diff($registeredIds) + ->sort() + ->values(); + return [ 'zones' => ZoneResource::collection(Zone::orderBy('name')->get())->resolve(), 'deviceTypes' => DeviceTypeResource::collection(DeviceType::orderBy('code')->get())->resolve(), + // external_id of devices device-control-service has already seen + // over MQTT but nobody has registered yet — offered as + // suggestions (not a closed list) when adding/editing a device. + 'unregisteredExternalIds' => $unregisteredExternalIds, ]; } } diff --git a/laravel-app/app/Services/DeviceShadow.php b/laravel-app/app/Services/DeviceShadow.php index 554603a..a636a33 100644 --- a/laravel-app/app/Services/DeviceShadow.php +++ b/laravel-app/app/Services/DeviceShadow.php @@ -54,4 +54,18 @@ class DeviceShadow return array_combine($externalIds, array_map(fn ($v) => $v ?: 'unknown', $values)); } + + /** + * external_id of every device device-control-service has ever heard + * from over MQTT (`devices:known`, a Redis Set it maintains) — includes + * devices that sent telemetry/ack before anyone registered them in + * Postgres. Used to suggest an external_id when adding a device instead + * of requiring it to be typed exactly from memory. + * + * @return array + */ + public function knownExternalIds(): array + { + return Redis::smembers('devices:known'); + } } diff --git a/laravel-app/resources/js/Pages/Devices/Create.vue b/laravel-app/resources/js/Pages/Devices/Create.vue index 380505c..3074d69 100644 --- a/laravel-app/resources/js/Pages/Devices/Create.vue +++ b/laravel-app/resources/js/Pages/Devices/Create.vue @@ -5,6 +5,7 @@ import Form from '@/Pages/Devices/Form.vue'; defineProps({ zones: { type: Array, required: true }, deviceTypes: { type: Array, required: true }, + unregisteredExternalIds: { type: Array, default: () => [] }, }); @@ -17,7 +18,7 @@ defineProps({
-
+
diff --git a/laravel-app/resources/js/Pages/Devices/Edit.vue b/laravel-app/resources/js/Pages/Devices/Edit.vue index 9f58e13..92b042f 100644 --- a/laravel-app/resources/js/Pages/Devices/Edit.vue +++ b/laravel-app/resources/js/Pages/Devices/Edit.vue @@ -6,6 +6,7 @@ defineProps({ device: { type: Object, required: true }, zones: { type: Array, required: true }, deviceTypes: { type: Array, required: true }, + unregisteredExternalIds: { type: Array, default: () => [] }, }); @@ -18,7 +19,7 @@ defineProps({
- +
diff --git a/laravel-app/resources/js/Pages/Devices/Form.vue b/laravel-app/resources/js/Pages/Devices/Form.vue index 0b00ca7..2feb9fd 100644 --- a/laravel-app/resources/js/Pages/Devices/Form.vue +++ b/laravel-app/resources/js/Pages/Devices/Form.vue @@ -6,6 +6,7 @@ const props = defineProps({ device: { type: Object, default: null }, zones: { type: Array, required: true }, deviceTypes: { type: Array, required: true }, + unregisteredExternalIds: { type: Array, default: () => [] }, }); const isEdit = !!props.device; @@ -76,9 +77,17 @@ function submit() { id="external_id" v-model="form.external_id" type="text" + list="unregistered-external-ids" required + placeholder="можно ввести вручную или выбрать увиденное устройство" class="mt-1 block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" > + + +

+ От {{ unregisteredExternalIds.length }} {{ unregisteredExternalIds.length === 1 ? 'устройства' : 'устройств' }} уже приходил сигнал, но они ещё не зарегистрированы — начните вводить, чтобы увидеть подсказки. +

{{ form.errors.external_id }}
diff --git a/laravel-app/tests/Feature/DeviceControllerTest.php b/laravel-app/tests/Feature/DeviceControllerTest.php index 71b5c14..161ff7f 100644 --- a/laravel-app/tests/Feature/DeviceControllerTest.php +++ b/laravel-app/tests/Feature/DeviceControllerTest.php @@ -130,4 +130,23 @@ class DeviceControllerTest extends TestCase ->where('telemetry.0.value', 24.5) ); } + + public function test_create_form_suggests_unregistered_external_ids(): void + { + $owner = User::factory()->create(['role' => UserRole::Owner]); + // Already registered — must be excluded even though device-control-service still knows it. + $this->makeDevice($owner, ['turn_on']); + + $this->mock(DeviceShadow::class, function ($mock) { + $mock->shouldReceive('knownExternalIds')->once()->andReturn(['fan-1', 'sensor-2']); + }); + + $response = $this->actingAs($owner)->get(route('devices.create')); + + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('Devices/Create') + ->where('unregisteredExternalIds', ['sensor-2']) + ); + } }